2026-04-16 22:01:51 +08:00
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum KernelNodeType {
|
|
|
|
|
|
Workspace,
|
|
|
|
|
|
Folder,
|
|
|
|
|
|
Page,
|
|
|
|
|
|
Section,
|
|
|
|
|
|
Asset,
|
2026-04-18 09:38:16 +08:00
|
|
|
|
Table,
|
2026-04-16 22:01:51 +08:00
|
|
|
|
Book,
|
|
|
|
|
|
Pdf,
|
|
|
|
|
|
Mindmap,
|
|
|
|
|
|
MindmapNode,
|
|
|
|
|
|
Summary,
|
|
|
|
|
|
AiNote,
|
|
|
|
|
|
ReferenceAnchor,
|
|
|
|
|
|
ContentNode,
|
|
|
|
|
|
IndexNode,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum KernelEdgeType {
|
|
|
|
|
|
ParentOf,
|
|
|
|
|
|
ChildOf,
|
|
|
|
|
|
Contains,
|
|
|
|
|
|
References,
|
|
|
|
|
|
BacklinksTo,
|
|
|
|
|
|
SourceOf,
|
|
|
|
|
|
DerivedFrom,
|
|
|
|
|
|
Summarizes,
|
|
|
|
|
|
Indexes,
|
|
|
|
|
|
PointsTo,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum KernelProjectionKind {
|
|
|
|
|
|
SidebarTree,
|
|
|
|
|
|
PageTree,
|
|
|
|
|
|
FileTree,
|
|
|
|
|
|
Mindmap,
|
|
|
|
|
|
ReadView,
|
|
|
|
|
|
SearchResults,
|
|
|
|
|
|
RagIndex,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 00:41:03 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum WorkspaceSourceKind {
|
|
|
|
|
|
LocalFolder,
|
|
|
|
|
|
ConvexWorkspace,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "kebab-case")]
|
|
|
|
|
|
pub enum WorkspaceSourceCapability {
|
|
|
|
|
|
LoadSnapshot,
|
|
|
|
|
|
Watch,
|
|
|
|
|
|
PreflightCommand,
|
|
|
|
|
|
ExecuteCommand,
|
|
|
|
|
|
ResolvePageAggregate,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct WorkspaceSource {
|
|
|
|
|
|
pub source_kind: WorkspaceSourceKind,
|
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
|
pub workspace_id: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub capabilities: Vec<WorkspaceSourceCapability>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 08:07:17 +08:00
|
|
|
|
fn default_page_body_content_format() -> String {
|
|
|
|
|
|
"editorBlocks".into()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct PageBodyWriteRequest {
|
|
|
|
|
|
pub document_id: String,
|
|
|
|
|
|
pub workspace_id: String,
|
|
|
|
|
|
pub source_kind: WorkspaceSourceKind,
|
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
|
#[serde(
|
|
|
|
|
|
default,
|
|
|
|
|
|
alias = "expected_file_version",
|
|
|
|
|
|
alias = "conflictDetectionKey",
|
|
|
|
|
|
alias = "conflict_detection_key"
|
|
|
|
|
|
)]
|
|
|
|
|
|
pub expected_file_version: Option<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub base_content_hash: Option<String>,
|
|
|
|
|
|
#[serde(default = "default_page_body_content_format")]
|
|
|
|
|
|
pub content_format: String,
|
|
|
|
|
|
pub content: Value,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub editor_source: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 09:38:16 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "kebab-case")]
|
|
|
|
|
|
pub enum KernelProjectionCapability {
|
|
|
|
|
|
Expand,
|
|
|
|
|
|
Open,
|
|
|
|
|
|
Drag,
|
|
|
|
|
|
Drop,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
CreateChild,
|
|
|
|
|
|
Rename,
|
|
|
|
|
|
Archive,
|
|
|
|
|
|
Restore,
|
|
|
|
|
|
ContextMenu,
|
|
|
|
|
|
Reorder,
|
|
|
|
|
|
OpenAsset,
|
|
|
|
|
|
Pick,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum KernelProjectionRowKind {
|
|
|
|
|
|
Document,
|
|
|
|
|
|
Index,
|
|
|
|
|
|
Asset,
|
|
|
|
|
|
AssetFolder,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum KernelProjectionResourceKind {
|
|
|
|
|
|
Workspace,
|
|
|
|
|
|
Document,
|
|
|
|
|
|
Index,
|
|
|
|
|
|
Asset,
|
|
|
|
|
|
AssetFolder,
|
|
|
|
|
|
Mindmap,
|
2026-05-13 22:43:16 +08:00
|
|
|
|
Attachment,
|
|
|
|
|
|
OnlyOffice,
|
|
|
|
|
|
Code,
|
2026-04-18 09:38:16 +08:00
|
|
|
|
Table,
|
|
|
|
|
|
Book,
|
|
|
|
|
|
Pdf,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum KernelProjectionAssetKind {
|
|
|
|
|
|
File,
|
|
|
|
|
|
Mindmap,
|
|
|
|
|
|
Table,
|
|
|
|
|
|
Book,
|
|
|
|
|
|
Pdf,
|
|
|
|
|
|
Image,
|
|
|
|
|
|
Video,
|
|
|
|
|
|
Audio,
|
|
|
|
|
|
Unknown,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 22:43:16 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum KernelObjectKind {
|
|
|
|
|
|
Page,
|
|
|
|
|
|
Index,
|
|
|
|
|
|
Mindmap,
|
|
|
|
|
|
Attachment,
|
|
|
|
|
|
OnlyOffice,
|
|
|
|
|
|
Code,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelObjectIdentity {
|
|
|
|
|
|
pub object_kind: KernelObjectKind,
|
|
|
|
|
|
pub document_id: Option<String>,
|
|
|
|
|
|
pub block_id: Option<String>,
|
|
|
|
|
|
pub asset_id: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 12:20:47 +08:00
|
|
|
|
/// DocumentBuffer 打开状态 — 表达当前编辑器缓冲区对文件的打开态、dirty 状态和文件版本仲裁。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 五种状态:Clean(未修改)、Dirty(已修改未保存)、Stale(版本过时)、
|
|
|
|
|
|
/// ExternalModified(外部已修改)、Deleted(外部已删除)。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Phase A2 收口后,tiptap autosave、AI 写入、外部 watcher 均围绕此状态仲裁。
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum DocBufferDirtyState {
|
|
|
|
|
|
Clean,
|
|
|
|
|
|
Dirty,
|
|
|
|
|
|
Stale,
|
|
|
|
|
|
ExternalModified,
|
|
|
|
|
|
Deleted,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for DocBufferDirtyState {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self::Clean
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// DocumentBuffer 最小字段模型 — 表示一个已打开文档的缓冲区状态。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 不是持久化结构,而是运行时模型,用于仲裁写入冲突和外部修改检测。
|
|
|
|
|
|
/// 每个打开的编辑器(tiptap / AI session / external editor)对应一个实例。
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentBuffer {
|
|
|
|
|
|
/// 文档在 workspace 中的统一路径身份
|
|
|
|
|
|
pub workspace_path: ObjectWorkspacePath,
|
|
|
|
|
|
/// 最后已知文件版本(来自 aggregate.fileVersion 或 disk mtime content-hash)
|
|
|
|
|
|
pub file_version: Option<String>,
|
|
|
|
|
|
/// 最后加载/保存时的内容 hash
|
|
|
|
|
|
pub base_content_hash: Option<String>,
|
|
|
|
|
|
/// 当前编辑器内容 hash(dirty 时与 base 不同)
|
|
|
|
|
|
pub current_content_hash: Option<String>,
|
|
|
|
|
|
/// 缓冲区 dirty 状态
|
|
|
|
|
|
pub dirty_state: DocBufferDirtyState,
|
|
|
|
|
|
/// 最后加载时间戳 (ms since epoch)
|
|
|
|
|
|
pub last_loaded_at: Option<i64>,
|
|
|
|
|
|
/// 最后保存时间戳 (ms since epoch)
|
|
|
|
|
|
pub last_saved_at: Option<i64>,
|
|
|
|
|
|
/// 外部修改者
|
|
|
|
|
|
pub external_actor: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl DocumentBuffer {
|
|
|
|
|
|
pub fn is_dirty(&self) -> bool {
|
|
|
|
|
|
self.dirty_state == DocBufferDirtyState::Dirty
|
|
|
|
|
|
|| self.dirty_state == DocBufferDirtyState::Stale
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn mark_dirty(&mut self, content_hash: String) {
|
|
|
|
|
|
self.current_content_hash = Some(content_hash);
|
|
|
|
|
|
self.dirty_state = DocBufferDirtyState::Dirty;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn mark_saved(&mut self, file_version: String, content_hash: String) {
|
|
|
|
|
|
self.file_version = Some(file_version);
|
|
|
|
|
|
self.base_content_hash = Some(content_hash);
|
|
|
|
|
|
self.current_content_hash = None;
|
|
|
|
|
|
self.dirty_state = DocBufferDirtyState::Clean;
|
|
|
|
|
|
self.last_saved_at = Some(
|
|
|
|
|
|
std::time::SystemTime::now()
|
|
|
|
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
|
.as_millis() as i64,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn mark_external_modified(&mut self, actor: Option<String>) {
|
|
|
|
|
|
self.external_actor = actor;
|
|
|
|
|
|
if self.is_dirty() {
|
|
|
|
|
|
self.dirty_state = DocBufferDirtyState::Stale;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.dirty_state = DocBufferDirtyState::ExternalModified;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn mark_deleted(&mut self) {
|
|
|
|
|
|
self.dirty_state = DocBufferDirtyState::Deleted;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 22:43:16 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelBlockAssetRelation {
|
|
|
|
|
|
pub document_id: String,
|
|
|
|
|
|
pub block_id: String,
|
|
|
|
|
|
pub asset_id: String,
|
|
|
|
|
|
pub asset_kind: KernelProjectionAssetKind,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 12:20:47 +08:00
|
|
|
|
/// 统一 workspace 路径身份 — 表达"哪个 workspace root 下的哪个对象/文件/资源"。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 这是 Phase A1 收口后的主身份类型,用于:
|
|
|
|
|
|
/// - File Tree / Page Tree / Resource Tree 的 resourceMeta
|
|
|
|
|
|
/// - 本地文件写入时的身份验证
|
|
|
|
|
|
/// - 前端 sidebar/filetree 的统一 identity 消费
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 相比旧的 `KernelObjectIdentity`,增加了 workspace 上下文:
|
|
|
|
|
|
/// `workspace_id`、`source_kind`、`root_uri`、`relative_path`。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 所有新代码应优先使用此类型;`KernelObjectIdentity` 保留为内核投影的兼容子集。
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct ObjectWorkspacePath {
|
|
|
|
|
|
pub workspace_id: String,
|
|
|
|
|
|
pub source_kind: WorkspaceSourceKind,
|
|
|
|
|
|
pub root_uri: String,
|
|
|
|
|
|
pub relative_path: String,
|
|
|
|
|
|
pub object_identity: KernelObjectIdentity,
|
|
|
|
|
|
pub resource_kind: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 22:01:51 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum KernelGraphDirection {
|
|
|
|
|
|
Outgoing,
|
|
|
|
|
|
Incoming,
|
|
|
|
|
|
Both,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelSubtreeRef {
|
|
|
|
|
|
pub root_node_id: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub path: Vec<String>,
|
|
|
|
|
|
pub depth: Option<u32>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelNodeMetadata {
|
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
|
pub icon: Option<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub tags: Vec<String>,
|
|
|
|
|
|
pub created_at: Option<String>,
|
|
|
|
|
|
pub updated_at: Option<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub extra: BTreeMap<String, Value>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelContentPayload {
|
|
|
|
|
|
pub format: String,
|
|
|
|
|
|
pub body: Value,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelRefsPayload {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub reference_node_ids: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub evidence_node_ids: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub source_node_ids: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub extra: BTreeMap<String, Value>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelAuditStamp {
|
|
|
|
|
|
pub version: u64,
|
|
|
|
|
|
pub revision: Option<u64>,
|
|
|
|
|
|
pub request_id: Option<String>,
|
|
|
|
|
|
pub trace_id: Option<String>,
|
|
|
|
|
|
pub actor_id: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for KernelAuditStamp {
|
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
version: 1,
|
|
|
|
|
|
revision: None,
|
|
|
|
|
|
request_id: None,
|
|
|
|
|
|
trace_id: None,
|
|
|
|
|
|
actor_id: None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelNode {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub node_type: KernelNodeType,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
pub parent_id: Option<String>,
|
|
|
|
|
|
pub subtree: Option<KernelSubtreeRef>,
|
|
|
|
|
|
pub metadata: KernelNodeMetadata,
|
|
|
|
|
|
pub content: Option<KernelContentPayload>,
|
|
|
|
|
|
pub refs: Option<KernelRefsPayload>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub audit: KernelAuditStamp,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelEdge {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub edge_type: KernelEdgeType,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
pub from_node_id: String,
|
|
|
|
|
|
pub to_node_id: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub metadata: BTreeMap<String, Value>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub audit: KernelAuditStamp,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelProjectionFilter {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub node_types: Vec<KernelNodeType>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub edge_types: Vec<KernelEdgeType>,
|
2026-04-26 19:35:52 +08:00
|
|
|
|
pub query: Option<String>,
|
|
|
|
|
|
pub max_results: Option<usize>,
|
2026-04-16 22:01:51 +08:00
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub include_deleted: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelGetNode {
|
|
|
|
|
|
pub node_id: String,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelGetSubtree {
|
|
|
|
|
|
pub subtree: KernelSubtreeRef,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub include_edges: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub filters: KernelProjectionFilter,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelListChildren {
|
|
|
|
|
|
pub parent_node_id: String,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub node_types: Vec<KernelNodeType>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelListEdges {
|
|
|
|
|
|
pub node_id: String,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub edge_types: Vec<KernelEdgeType>,
|
|
|
|
|
|
pub direction: Option<KernelGraphDirection>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelTraverseGraph {
|
|
|
|
|
|
pub start_node_id: String,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub edge_types: Vec<KernelEdgeType>,
|
|
|
|
|
|
pub max_depth: u32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelProjectionRequest {
|
|
|
|
|
|
pub projection: KernelProjectionKind,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
pub root_node_id: Option<String>,
|
|
|
|
|
|
pub subtree: Option<KernelSubtreeRef>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub filters: KernelProjectionFilter,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub include_content: bool,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub include_edges: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelSubtreeResult {
|
|
|
|
|
|
pub root_node_id: String,
|
|
|
|
|
|
pub nodes: Vec<KernelNode>,
|
|
|
|
|
|
pub edges: Vec<KernelEdge>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelEdgeListResult {
|
|
|
|
|
|
pub node_id: String,
|
|
|
|
|
|
pub edges: Vec<KernelEdge>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelGraphVisit {
|
|
|
|
|
|
pub node_id: String,
|
|
|
|
|
|
pub depth: u32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelGraphTraversalResult {
|
|
|
|
|
|
pub start_node_id: String,
|
|
|
|
|
|
pub visited: Vec<KernelGraphVisit>,
|
|
|
|
|
|
pub edges: Vec<KernelEdge>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelProjectionItem {
|
2026-04-18 09:38:16 +08:00
|
|
|
|
pub row_id: String,
|
|
|
|
|
|
pub row_kind: KernelProjectionRowKind,
|
2026-04-16 22:01:51 +08:00
|
|
|
|
pub node_id: String,
|
|
|
|
|
|
pub parent_node_id: Option<String>,
|
|
|
|
|
|
pub node_type: KernelNodeType,
|
2026-04-18 09:38:16 +08:00
|
|
|
|
pub projection_kind: KernelProjectionKind,
|
2026-04-16 22:01:51 +08:00
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
|
pub depth: u32,
|
|
|
|
|
|
pub position: Option<i64>,
|
|
|
|
|
|
pub child_count: u32,
|
2026-04-18 09:38:16 +08:00
|
|
|
|
pub expandable: bool,
|
2026-04-16 22:01:51 +08:00
|
|
|
|
pub expanded_by_default: bool,
|
2026-04-18 09:38:16 +08:00
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub capabilities: Vec<KernelProjectionCapability>,
|
|
|
|
|
|
pub resource_meta: Option<KernelProjectionResourceMeta>,
|
|
|
|
|
|
pub icon_hint: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelProjectionResourceMeta {
|
|
|
|
|
|
pub resource_kind: Option<KernelProjectionResourceKind>,
|
|
|
|
|
|
pub document_id: Option<String>,
|
|
|
|
|
|
pub asset_id: Option<String>,
|
|
|
|
|
|
pub workspace_id: Option<String>,
|
|
|
|
|
|
pub asset_kind: Option<KernelProjectionAssetKind>,
|
|
|
|
|
|
pub icon_hint: Option<String>,
|
2026-05-13 22:43:16 +08:00
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub object_identity: Option<KernelObjectIdentity>,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub block_asset_relation: Option<KernelBlockAssetRelation>,
|
2026-04-18 09:38:16 +08:00
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub extra: BTreeMap<String, Value>,
|
2026-04-16 22:01:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelProjectionResult {
|
|
|
|
|
|
pub projection_id: String,
|
|
|
|
|
|
pub projection: KernelProjectionKind,
|
|
|
|
|
|
pub root_node_id: Option<String>,
|
|
|
|
|
|
pub items: Vec<KernelProjectionItem>,
|
|
|
|
|
|
pub edges: Vec<KernelEdge>,
|
2026-04-28 16:30:51 +08:00
|
|
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
|
|
|
|
pub meta: BTreeMap<String, Value>,
|
2026-04-16 22:01:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 21:03:25 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum DocumentReadNodeType {
|
|
|
|
|
|
Page,
|
|
|
|
|
|
Section,
|
|
|
|
|
|
ContentNode,
|
|
|
|
|
|
ReferenceAnchor,
|
|
|
|
|
|
Mindmap,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentReadNodeMeta {
|
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
|
pub text_snippet: Option<String>,
|
|
|
|
|
|
pub block_type: Option<String>,
|
|
|
|
|
|
pub heading_level: Option<u32>,
|
|
|
|
|
|
pub numbering: Option<String>,
|
|
|
|
|
|
pub child_count: u32,
|
|
|
|
|
|
pub order: u32,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub path: Vec<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentReadNode {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub parent_node_id: Option<String>,
|
|
|
|
|
|
pub node_type: DocumentReadNodeType,
|
|
|
|
|
|
pub block_id: Option<String>,
|
|
|
|
|
|
pub anchor_block_id: Option<String>,
|
|
|
|
|
|
pub depth: u32,
|
|
|
|
|
|
pub metadata: DocumentReadNodeMeta,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentReadSubtree {
|
|
|
|
|
|
pub root_node_id: String,
|
|
|
|
|
|
pub nodes: Vec<DocumentReadNode>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentReadOutlineEntry {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub node_id: String,
|
|
|
|
|
|
pub anchor_block_id: Option<String>,
|
|
|
|
|
|
pub title: String,
|
|
|
|
|
|
pub level: u32,
|
|
|
|
|
|
pub numbering: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
|
pub enum DocumentReadEvidenceKind {
|
|
|
|
|
|
Page,
|
|
|
|
|
|
Heading,
|
|
|
|
|
|
Paragraph,
|
|
|
|
|
|
List,
|
|
|
|
|
|
Todo,
|
|
|
|
|
|
Quote,
|
|
|
|
|
|
Code,
|
|
|
|
|
|
Media,
|
|
|
|
|
|
Reference,
|
|
|
|
|
|
Table,
|
|
|
|
|
|
Mindmap,
|
|
|
|
|
|
Text,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentReadEvidenceItem {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub node_id: String,
|
|
|
|
|
|
pub block_id: Option<String>,
|
|
|
|
|
|
pub kind: DocumentReadEvidenceKind,
|
|
|
|
|
|
pub snippet: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentReadStats {
|
|
|
|
|
|
pub block_count: u32,
|
|
|
|
|
|
pub heading_count: u32,
|
|
|
|
|
|
pub evidence_count: u32,
|
|
|
|
|
|
pub max_depth: u32,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentReadPageSubtree {
|
|
|
|
|
|
pub projection_id: String,
|
|
|
|
|
|
pub projection: String,
|
|
|
|
|
|
pub root_node_id: String,
|
|
|
|
|
|
pub root_node: DocumentReadNode,
|
|
|
|
|
|
pub subtree: DocumentReadSubtree,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub outline: Vec<DocumentReadOutlineEntry>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub evidence: Vec<DocumentReadEvidenceItem>,
|
|
|
|
|
|
pub stats: DocumentReadStats,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct DocumentContentResult {
|
|
|
|
|
|
pub content: Value,
|
2026-05-19 08:07:17 +08:00
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub editor_document: Option<Value>,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub tiptap_document: Option<Value>,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub block_document: Option<Value>,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub block_projection_version: Option<u32>,
|
2026-04-19 21:03:25 +08:00
|
|
|
|
pub revision: u64,
|
|
|
|
|
|
pub conflict_detection_key: String,
|
|
|
|
|
|
pub title: Option<String>,
|
|
|
|
|
|
pub page_subtree: DocumentReadPageSubtree,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 22:01:51 +08:00
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelCreateNode {
|
|
|
|
|
|
pub node: KernelNode,
|
|
|
|
|
|
pub position: Option<i64>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelUpdateNode {
|
|
|
|
|
|
pub node_id: String,
|
|
|
|
|
|
pub metadata: Option<KernelNodeMetadata>,
|
|
|
|
|
|
pub content: Option<KernelContentPayload>,
|
|
|
|
|
|
pub refs: Option<KernelRefsPayload>,
|
|
|
|
|
|
pub audit: Option<KernelAuditStamp>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelMoveSubtree {
|
|
|
|
|
|
pub subtree: KernelSubtreeRef,
|
|
|
|
|
|
pub new_parent_node_id: Option<String>,
|
|
|
|
|
|
pub sort_order: Option<i64>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelAttachEdge {
|
|
|
|
|
|
pub edge: KernelEdge,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
|
pub struct KernelDetachEdge {
|
|
|
|
|
|
pub edge_id: Option<String>,
|
|
|
|
|
|
pub from_node_id: Option<String>,
|
|
|
|
|
|
pub to_node_id: Option<String>,
|
|
|
|
|
|
pub edge_type: Option<KernelEdgeType>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn kernel_projection_request_roundtrip_keeps_subtree_and_filters() {
|
|
|
|
|
|
let request = KernelProjectionRequest {
|
|
|
|
|
|
projection: KernelProjectionKind::SidebarTree,
|
|
|
|
|
|
workspace_id: Some("ws_1".into()),
|
|
|
|
|
|
root_node_id: Some("page_root".into()),
|
|
|
|
|
|
subtree: Some(KernelSubtreeRef {
|
|
|
|
|
|
root_node_id: "page_root".into(),
|
|
|
|
|
|
path: vec!["page_root".into(), "page_child".into()],
|
|
|
|
|
|
depth: Some(2),
|
|
|
|
|
|
}),
|
|
|
|
|
|
filters: KernelProjectionFilter {
|
|
|
|
|
|
node_types: vec![KernelNodeType::Page, KernelNodeType::Folder],
|
|
|
|
|
|
edge_types: vec![KernelEdgeType::ParentOf],
|
2026-04-26 19:35:52 +08:00
|
|
|
|
query: Some("预算".into()),
|
|
|
|
|
|
max_results: Some(20),
|
2026-04-16 22:01:51 +08:00
|
|
|
|
include_deleted: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
include_content: false,
|
|
|
|
|
|
include_edges: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let value = serde_json::to_value(&request).expect("request 应可序列化");
|
|
|
|
|
|
assert_eq!(value["projection"], json!("sidebar_tree"));
|
|
|
|
|
|
assert_eq!(value["subtree"]["rootNodeId"], json!("page_root"));
|
2026-04-26 19:35:52 +08:00
|
|
|
|
assert_eq!(value["filters"]["query"], json!("预算"));
|
|
|
|
|
|
assert_eq!(value["filters"]["maxResults"], json!(20));
|
2026-04-16 22:01:51 +08:00
|
|
|
|
|
|
|
|
|
|
let decoded: KernelProjectionRequest =
|
|
|
|
|
|
serde_json::from_value(value).expect("request 应可反序列化");
|
|
|
|
|
|
assert_eq!(decoded, request);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn kernel_node_serializes_content_refs_and_audit_boundaries() {
|
|
|
|
|
|
let node = KernelNode {
|
|
|
|
|
|
id: "node_1".into(),
|
|
|
|
|
|
node_type: KernelNodeType::Summary,
|
|
|
|
|
|
workspace_id: Some("ws_1".into()),
|
|
|
|
|
|
parent_id: Some("page_1".into()),
|
|
|
|
|
|
subtree: Some(KernelSubtreeRef {
|
|
|
|
|
|
root_node_id: "page_1".into(),
|
|
|
|
|
|
path: vec!["page_1".into(), "node_1".into()],
|
|
|
|
|
|
depth: Some(1),
|
|
|
|
|
|
}),
|
|
|
|
|
|
metadata: KernelNodeMetadata {
|
|
|
|
|
|
title: Some("摘要节点".into()),
|
|
|
|
|
|
icon: None,
|
|
|
|
|
|
tags: vec!["summary".into()],
|
|
|
|
|
|
created_at: Some("2026-04-16T00:00:00Z".into()),
|
|
|
|
|
|
updated_at: Some("2026-04-16T00:00:00Z".into()),
|
|
|
|
|
|
extra: BTreeMap::new(),
|
|
|
|
|
|
},
|
|
|
|
|
|
content: Some(KernelContentPayload {
|
|
|
|
|
|
format: "markdown".into(),
|
|
|
|
|
|
body: json!({"text": "摘要正文"}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
refs: Some(KernelRefsPayload {
|
|
|
|
|
|
reference_node_ids: vec!["page_1".into()],
|
|
|
|
|
|
evidence_node_ids: vec!["asset_1".into()],
|
|
|
|
|
|
source_node_ids: vec!["pdf_1".into()],
|
|
|
|
|
|
extra: BTreeMap::new(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
audit: KernelAuditStamp {
|
|
|
|
|
|
version: 3,
|
|
|
|
|
|
revision: Some(9),
|
|
|
|
|
|
request_id: Some("req_1".into()),
|
|
|
|
|
|
trace_id: Some("trace_1".into()),
|
|
|
|
|
|
actor_id: Some("user_1".into()),
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let value = serde_json::to_value(&node).expect("node 应可序列化");
|
|
|
|
|
|
assert_eq!(value["nodeType"], json!("summary"));
|
|
|
|
|
|
assert_eq!(value["content"]["format"], json!("markdown"));
|
|
|
|
|
|
assert_eq!(value["refs"]["referenceNodeIds"], json!(["page_1"]));
|
|
|
|
|
|
assert_eq!(value["audit"]["traceId"], json!("trace_1"));
|
|
|
|
|
|
}
|
2026-04-18 09:38:16 +08:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn kernel_projection_item_serializes_contract_fields() {
|
|
|
|
|
|
let item = KernelProjectionItem {
|
|
|
|
|
|
row_id: "asset:page_1".into(),
|
|
|
|
|
|
row_kind: KernelProjectionRowKind::Asset,
|
|
|
|
|
|
node_id: "page_1".into(),
|
|
|
|
|
|
parent_node_id: None,
|
|
|
|
|
|
node_type: KernelNodeType::Page,
|
|
|
|
|
|
projection_kind: KernelProjectionKind::FileTree,
|
|
|
|
|
|
title: Some("页面 1".into()),
|
|
|
|
|
|
depth: 0,
|
|
|
|
|
|
position: Some(1),
|
|
|
|
|
|
child_count: 2,
|
|
|
|
|
|
expandable: true,
|
|
|
|
|
|
expanded_by_default: true,
|
|
|
|
|
|
capabilities: vec![
|
|
|
|
|
|
KernelProjectionCapability::Expand,
|
|
|
|
|
|
KernelProjectionCapability::Open,
|
|
|
|
|
|
KernelProjectionCapability::CreateChild,
|
|
|
|
|
|
],
|
|
|
|
|
|
resource_meta: Some(KernelProjectionResourceMeta {
|
|
|
|
|
|
resource_kind: Some(KernelProjectionResourceKind::Document),
|
|
|
|
|
|
document_id: Some("page_1".into()),
|
|
|
|
|
|
asset_id: None,
|
|
|
|
|
|
workspace_id: Some("ws_1".into()),
|
|
|
|
|
|
asset_kind: Some(KernelProjectionAssetKind::File),
|
|
|
|
|
|
icon_hint: Some("page".into()),
|
2026-05-13 22:43:16 +08:00
|
|
|
|
object_identity: None,
|
|
|
|
|
|
block_asset_relation: None,
|
2026-04-18 09:38:16 +08:00
|
|
|
|
extra: BTreeMap::new(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
icon_hint: Some("page".into()),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let value = serde_json::to_value(&item).expect("projection item 应可序列化");
|
|
|
|
|
|
assert_eq!(value["rowId"], json!("asset:page_1"));
|
|
|
|
|
|
assert_eq!(value["rowKind"], json!("asset"));
|
|
|
|
|
|
assert_eq!(value["projectionKind"], json!("file_tree"));
|
2026-04-19 21:03:25 +08:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
value["capabilities"],
|
|
|
|
|
|
json!(["expand", "open", "create-child"])
|
|
|
|
|
|
);
|
2026-04-18 09:38:16 +08:00
|
|
|
|
assert_eq!(value["resourceMeta"]["resourceKind"], json!("document"));
|
|
|
|
|
|
assert_eq!(value["iconHint"], json!("page"));
|
|
|
|
|
|
}
|
2026-05-08 00:41:03 +08:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn workspace_source_serializes_minimal_command_source_contract() {
|
|
|
|
|
|
let source = WorkspaceSource {
|
|
|
|
|
|
source_kind: WorkspaceSourceKind::ConvexWorkspace,
|
|
|
|
|
|
root_uri: "convex://workspace/ws_1".into(),
|
|
|
|
|
|
workspace_id: "ws_1".into(),
|
|
|
|
|
|
capabilities: vec![
|
|
|
|
|
|
WorkspaceSourceCapability::LoadSnapshot,
|
|
|
|
|
|
WorkspaceSourceCapability::PreflightCommand,
|
|
|
|
|
|
WorkspaceSourceCapability::ExecuteCommand,
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let value = serde_json::to_value(&source).expect("workspace source 应可序列化");
|
|
|
|
|
|
assert_eq!(value["sourceKind"], json!("convex_workspace"));
|
|
|
|
|
|
assert_eq!(value["rootUri"], json!("convex://workspace/ws_1"));
|
|
|
|
|
|
assert_eq!(value["workspaceId"], json!("ws_1"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
value["capabilities"],
|
|
|
|
|
|
json!(["load-snapshot", "preflight-command", "execute-command"])
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let decoded: WorkspaceSource =
|
|
|
|
|
|
serde_json::from_value(value).expect("workspace source 应可反序列化");
|
|
|
|
|
|
assert_eq!(decoded, source);
|
|
|
|
|
|
}
|
2026-05-19 08:07:17 +08:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn page_body_write_request_uses_file_version_contract() {
|
|
|
|
|
|
let request: PageBodyWriteRequest = serde_json::from_value(json!({
|
|
|
|
|
|
"documentId": "local-md:README.md",
|
|
|
|
|
|
"workspaceId": "local-ws:user:my-space",
|
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
|
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space",
|
|
|
|
|
|
"expectedFileVersion": "local-md:local-md:README.md:1:2:hash",
|
|
|
|
|
|
"baseContentHash": "sha256:base",
|
|
|
|
|
|
"contentFormat": "editorBlocks",
|
|
|
|
|
|
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}],
|
|
|
|
|
|
"editorSource": "tiptap"
|
|
|
|
|
|
}))
|
|
|
|
|
|
.expect("page.body.write request 应可反序列化");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(request.document_id, "local-md:README.md");
|
|
|
|
|
|
assert_eq!(request.source_kind, WorkspaceSourceKind::LocalFolder);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
request.expected_file_version.as_deref(),
|
|
|
|
|
|
Some("local-md:local-md:README.md:1:2:hash")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(request.content_format, "editorBlocks");
|
|
|
|
|
|
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
|
|
|
|
|
|
|
|
|
|
|
|
let value = serde_json::to_value(&request).expect("request 应可序列化");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
value["expectedFileVersion"],
|
|
|
|
|
|
json!("local-md:local-md:README.md:1:2:hash")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(value["baseContentHash"], json!("sha256:base"));
|
|
|
|
|
|
assert_eq!(value["contentFormat"], json!("editorBlocks"));
|
|
|
|
|
|
assert_eq!(value["editorSource"], json!("tiptap"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn page_body_write_request_accepts_legacy_conflict_key_alias() {
|
|
|
|
|
|
let request: PageBodyWriteRequest = serde_json::from_value(json!({
|
|
|
|
|
|
"documentId": "local-md:README.md",
|
|
|
|
|
|
"workspaceId": "local-ws:user:my-space",
|
|
|
|
|
|
"sourceKind": "local_folder",
|
|
|
|
|
|
"rootUri": "file:///tmp/workspace",
|
|
|
|
|
|
"conflictDetectionKey": "legacy-key",
|
|
|
|
|
|
"content": []
|
|
|
|
|
|
}))
|
|
|
|
|
|
.expect("legacy compat request 应可反序列化");
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(request.expected_file_version.as_deref(), Some("legacy-key"));
|
|
|
|
|
|
assert_eq!(request.content_format, "editorBlocks");
|
|
|
|
|
|
assert_eq!(request.base_content_hash, None);
|
|
|
|
|
|
assert_eq!(request.editor_source, None);
|
|
|
|
|
|
}
|
2026-05-19 12:20:47 +08:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn object_workspace_path_round_trip() {
|
|
|
|
|
|
let path = ObjectWorkspacePath {
|
|
|
|
|
|
workspace_id: "local-ws:user:my-space".into(),
|
|
|
|
|
|
source_kind: WorkspaceSourceKind::LocalFolder,
|
|
|
|
|
|
root_uri: "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space".into(),
|
|
|
|
|
|
relative_path: "docs/README.md".into(),
|
|
|
|
|
|
object_identity: KernelObjectIdentity {
|
|
|
|
|
|
object_kind: KernelObjectKind::Page,
|
|
|
|
|
|
document_id: Some("local-md:docs~2FREADME.md".into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
asset_id: None,
|
|
|
|
|
|
},
|
|
|
|
|
|
resource_kind: Some("document".into()),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let value = serde_json::to_value(&path).expect("ObjectWorkspacePath 应可序列化");
|
|
|
|
|
|
assert_eq!(value["workspaceId"], json!("local-ws:user:my-space"));
|
|
|
|
|
|
assert_eq!(value["sourceKind"], json!("local_folder"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
value["rootUri"],
|
|
|
|
|
|
json!("file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(value["relativePath"], json!("docs/README.md"));
|
|
|
|
|
|
assert_eq!(value["objectIdentity"]["objectKind"], json!("page"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
value["objectIdentity"]["documentId"],
|
|
|
|
|
|
json!("local-md:docs~2FREADME.md")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(value["resourceKind"], json!("document"));
|
|
|
|
|
|
|
|
|
|
|
|
let decoded: ObjectWorkspacePath =
|
|
|
|
|
|
serde_json::from_value(value).expect("ObjectWorkspacePath 应可反序列化");
|
|
|
|
|
|
assert_eq!(decoded, path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn object_workspace_path_accepts_resource_kind_null() {
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
|
struct Minimal {
|
|
|
|
|
|
resource_kind: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
let value = serde_json::json!({
|
|
|
|
|
|
"workspaceId": "ws_1",
|
|
|
|
|
|
"sourceKind": "convex_workspace",
|
|
|
|
|
|
"rootUri": "convex://ws",
|
|
|
|
|
|
"relativePath": "doc.md",
|
|
|
|
|
|
"objectIdentity": {
|
|
|
|
|
|
"objectKind": "page",
|
|
|
|
|
|
"documentId": "doc_1",
|
|
|
|
|
|
"blockId": null,
|
|
|
|
|
|
"assetId": null
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
let path: ObjectWorkspacePath =
|
|
|
|
|
|
serde_json::from_value(value).expect("resource_kind null 应接受");
|
|
|
|
|
|
assert_eq!(path.resource_kind, None);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn document_buffer_round_trip() {
|
|
|
|
|
|
let identity = KernelObjectIdentity {
|
|
|
|
|
|
object_kind: KernelObjectKind::Page,
|
|
|
|
|
|
document_id: Some("local-md:doc.md".into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
asset_id: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let path = ObjectWorkspacePath {
|
|
|
|
|
|
workspace_id: "ws_1".into(),
|
|
|
|
|
|
source_kind: WorkspaceSourceKind::LocalFolder,
|
|
|
|
|
|
root_uri: "file:///tmp/root".into(),
|
|
|
|
|
|
relative_path: "doc.md".into(),
|
|
|
|
|
|
object_identity: identity,
|
|
|
|
|
|
resource_kind: Some("document".into()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let buf = DocumentBuffer {
|
|
|
|
|
|
workspace_path: path,
|
|
|
|
|
|
file_version: Some("v1".into()),
|
|
|
|
|
|
base_content_hash: Some("sha256:abc".into()),
|
|
|
|
|
|
current_content_hash: None,
|
|
|
|
|
|
dirty_state: DocBufferDirtyState::Clean,
|
|
|
|
|
|
last_loaded_at: Some(1_700_000_000_000i64),
|
|
|
|
|
|
last_saved_at: Some(1_700_000_000_001i64),
|
|
|
|
|
|
external_actor: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化");
|
|
|
|
|
|
assert_eq!(value["dirtyState"], json!("clean"));
|
|
|
|
|
|
assert_eq!(value["fileVersion"], json!("v1"));
|
|
|
|
|
|
assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md"));
|
|
|
|
|
|
|
|
|
|
|
|
let decoded: DocumentBuffer =
|
|
|
|
|
|
serde_json::from_value(value).expect("DocumentBuffer 应可反序列化");
|
|
|
|
|
|
assert_eq!(decoded, buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn document_buffer_state_transitions() {
|
|
|
|
|
|
let identity = KernelObjectIdentity {
|
|
|
|
|
|
object_kind: KernelObjectKind::Page,
|
|
|
|
|
|
document_id: Some("local-md:doc.md".into()),
|
|
|
|
|
|
block_id: None,
|
|
|
|
|
|
asset_id: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let path = ObjectWorkspacePath {
|
|
|
|
|
|
workspace_id: "ws_1".into(),
|
|
|
|
|
|
source_kind: WorkspaceSourceKind::LocalFolder,
|
|
|
|
|
|
root_uri: "file:///tmp/root".into(),
|
|
|
|
|
|
relative_path: "doc.md".into(),
|
|
|
|
|
|
object_identity: identity,
|
|
|
|
|
|
resource_kind: Some("document".into()),
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut buf = DocumentBuffer {
|
|
|
|
|
|
workspace_path: path,
|
|
|
|
|
|
file_version: Some("v1".into()),
|
|
|
|
|
|
base_content_hash: Some("sha256:abc".into()),
|
|
|
|
|
|
current_content_hash: None,
|
|
|
|
|
|
dirty_state: DocBufferDirtyState::Clean,
|
|
|
|
|
|
last_loaded_at: None,
|
|
|
|
|
|
last_saved_at: None,
|
|
|
|
|
|
external_actor: None,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
assert!(!buf.is_dirty());
|
|
|
|
|
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
|
|
|
|
|
|
|
|
|
|
|
|
buf.mark_dirty("sha256:dirty".into());
|
|
|
|
|
|
assert!(buf.is_dirty());
|
|
|
|
|
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Dirty);
|
|
|
|
|
|
|
|
|
|
|
|
buf.mark_external_modified(Some("external-editor".into()));
|
|
|
|
|
|
assert!(buf.is_dirty());
|
|
|
|
|
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale);
|
|
|
|
|
|
assert_eq!(buf.external_actor.as_deref(), Some("external-editor"));
|
|
|
|
|
|
|
|
|
|
|
|
buf.mark_saved("v2".into(), "sha256:saved".into());
|
|
|
|
|
|
assert!(!buf.is_dirty());
|
|
|
|
|
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
|
|
|
|
|
|
assert_eq!(buf.file_version.as_deref(), Some("v2"));
|
|
|
|
|
|
|
|
|
|
|
|
buf.mark_external_modified(None);
|
|
|
|
|
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::ExternalModified);
|
|
|
|
|
|
|
|
|
|
|
|
buf.mark_deleted();
|
|
|
|
|
|
assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted);
|
|
|
|
|
|
}
|
2026-04-16 22:01:51 +08:00
|
|
|
|
}
|