diff --git a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md index 439d9773..70f95f13 100644 --- a/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md +++ b/design/01-tree-first-graph-kernel/process/1-3-current-priority-execution-checklist-v1.md @@ -297,7 +297,9 @@ - 证据:`/mindmap/{documentId}/{mindmapId}` 对象壳输出 `resource:mindmap:{documentId}:{mindmapId}`;`mindmap_shell_returns_rust_object_shell_contract`;保存仍通过 `mindmaps.put` / `mindmap.command.apply` 兼容链。 - [x] 盘点 office 当前 object identity、保存路径、打开路径。 - 证据:新增 `/office/{documentId}/{assetId}` 对象壳输出 `resource:onlyoffice:{documentId}:{assetId}`,内部复用 `/onlyoffice` bootstrap;`cargo test -p mnote-web onlyoffice_object_shell_exposes_resource_identity -- --nocapture`;`cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --nocapture`。 -- [ ] 统一资源创建、重命名、移动、删除、恢复命令到 `tree.resource.*`。 +- [x] 统一资源创建、重命名、移动、删除、恢复命令到 `tree.resource.*`。 + - 实现:上传/创建走 `tree.resource.upload` 或资源对象专用创建事件;附件 rename / move / archive / restore / purge 已输出 `tree.resource.rename/move/archive/restore/purge`;mindmap/table 生命周期输出 `tree.resource.*`。 + - 验证:`cargo test -p mnote-web resource_ -- --nocapture`;新增 `resource_move_uses_resource_command_not_document_command`。 - [x] filetree 显示资源行,pagetree 只显示页面导航投影。 - 验证:`cargo test -p mnote-web file_tree_projection_ -- --nocapture`;`cargo test -p bridge-runtime file_tree_projection -- --nocapture`。 - [x] Markdown 中插入资源引用时,只写相对链接或嵌入引用。 @@ -309,8 +311,9 @@ - 设计:`design/07-ai/process/7-28-resource-ai-tool-contract-v1.md`。 - [x] 设计 `mnote.office.*` 工具:读取文本摘要、写入建议、导出变更摘要;真实编辑优先复用 officecli / OnlyOffice 保存链。 - 设计:`design/07-ai/process/7-28-resource-ai-tool-contract-v1.md`。 -- [ ] AI 资源工具必须走授权 root 和 resource capability。 - - 设计已冻结:`design/07-ai/process/7-28-resource-ai-tool-contract-v1.md`;代码执行仍待接入 `mnote.mindmap.*` / `mnote.office.*` 工具面。 +- [x] AI 资源工具必须走授权 root 和 resource capability。 + - 实现:Hermes manifest 已暴露 `mnote.mindmap.fetch` / `mnote.mindmap.apply_ops` / `mnote.office.fetch_summary` / `mnote.office.propose_changes`;执行侧统一校验 `rootUri` 本地授权与 `allowedResourceIds` / object identity。 + - 验证:`cargo test -p mnote-web hermes_tools_resource -- --nocapture`;`cargo test -p mnote-web hermes_tools_mindmap -- --nocapture`;`cargo test -p mnote-web hermes_tools_office -- --nocapture`;`cargo test -p mnote-web hermes_tools_manifest_returns_first_batch_tools -- --nocapture`。 - [x] agent changed files 审计包含 mindmap / office 资源文件。 - 验证:`cargo test -p mnote-web local_agent_audit_snapshot_detects -- --nocapture`;覆盖 `.mindmap.json` 和同大小二进制 `.docx` 内容变更。 @@ -321,6 +324,7 @@ - [x] 单测:mindmap resource 删除 / 恢复不破坏 markdown 页面。 - 验证:`cargo test -p mnote-web mindmap_delete_restore_keeps_markdown_reference_out_of_lifecycle_command -- --nocapture`;生命周期命令只返回 `tree.resource.archive/restore` 和 `resourceKind=mindmap`,不携带正文 `documentId` 写入语义。 - [ ] browser smoke:从 markdown 打开 mindmap / office,修改保存后 filetree 和引用状态同步。 + - 脚本:`scripts/task456-resource-object-shell-sync-smoke.js` 已补,待 `3000` 主入口运行后执行。 --- @@ -371,9 +375,12 @@ ### 8.1 Page Aggregate 单一真源 -- [ ] 盘点 `documents.content` 仍作为正文兼容源的入口。 +- [x] 盘点 `documents.content` 仍作为正文兼容源的入口。 + - 证据:`routes/web_shell.rs` 非 local 分支仍以 `load_document_meta_result + load_document_content_result` 拼 `meta + content` 给 `page.aggregate.get`;`routes/documents.rs` 仍保留 `documents.content.get` compat;`bridge-runtime` 仍将 `CompatMetaContentJoin` / legacy `documents.content` 投影为 `blockDocument`。 - [ ] 让本地 `.md` 与 EditorBlockDocument projection 的读写边界写入 5-5 / 5-6。 - [ ] 标题、正文、页面设置写入后只通过 page aggregate 刷新 UI。 + - 阶段进展:文档壳初始化、外部刷新和冲突比较已优先消费 `body.blockDocument` / `editorDocument`,再 fallback 到 legacy `body.content`。 + - 验证:`cargo test -p mnote-web document_shell_returns_page_aggregate_snapshot -- --nocapture`;`cargo test -p mnote-web page_aggregate -- --nocapture`。 - [ ] 删除或降级前端手工拼 `meta + content` 的 runtime fallback。 - [ ] AI 页面设置写入走统一 page aggregate / page command 入口。 @@ -384,7 +391,8 @@ ### 8.2 Tree command cutover -- [ ] 盘点仍在 runtime route / adapter / bridge / CLI 中使用的 `documents.*` 命令。 +- [x] 盘点仍在 runtime route / adapter / bridge / CLI 中使用的 `documents.*` 命令。 + - 证据:`routes/mod.rs` 仍注册 `/api/documents/*` compat;`transport/convex.rs` 仍做 `documents.*` 到 legacy Convex validator 的适配;`bridge-runtime` 仍有 `documents.stats.update`、`documents.duplicate`、`documents.template`、`documents.emptyTrashByWorkspace` 等历史命名。 - [ ] 新增命令统一命名为 `tree.*` 或 `tree.resource.*`。 - [ ] 对历史 `documents.*` 命令只保留 compat adapter,不再扩展新语义。 - [ ] 资源 rename、移动、删除、恢复补齐 `tree.resource.*`。 @@ -396,9 +404,13 @@ ### 8.3 Tree realtime live cache -- [ ] 盘点 Sidebar、page subtree、filetree、preferred snapshot 的数据来源。 +- [x] 盘点 Sidebar、page subtree、filetree、preferred snapshot 的数据来源。 + - 证据:Sidebar/filetree 已消费 Rust projection 与 live tree events;local folder 仍有 `/api/tree/local-folder-watch` + HTML refetch 补偿链;WS 主链已启用 `/api/realtime/ws`,SSE 为 close 后 fallback。 - [ ] 统一 snapshot / delta / resync consumer 到同一 live cache。 -- [ ] SSE fallback 只作为 WS 不可用时的降级,不作为并行主链。 + - 阶段进展:WS `delta` 不再无条件 `resync`,仅 `resync_required` / `requiresResync` 时请求服务端 resync,普通 delta 先交给本地 live consumer。 + - 验证:`cargo test -p mnote-web tree_live_controller_marks_transport_and_closes_source_on_pagehide -- --nocapture`。 +- [x] SSE fallback 只作为 WS 不可用时的降级,不作为并行主链。 + - 证据:`TREE_LIVE_CONTROLLER_JS` 在 `transport=convex-command-log-ws` 时优先 `startWithWebSocket`,`onclose` 后才 `startWithSseFallback`;`cargo test -p mnote-web tree_live_controller_marks_transport_and_closes_source_on_pagehide -- --nocapture`。 - [ ] 双浏览器文件树操作无刷新同步。 - [ ] 删除不必要的 polling / refetch 补偿链。 @@ -438,6 +450,7 @@ - [x] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。 - 证据:`cargo test -p mnote-web share_grant -- --nocapture`;`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`;`cargo test -p mnote-web shared_cache -- --nocapture`;`node scripts/task454-shared-read-page-ai-browser-smoke.js`。 - [ ] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。 + - 阶段进展:资源命令面、资源对象壳、AI resource tools 与 changed-files 审计已闭环;剩余真实 browser smoke 待跑。 - [x] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。 - 证据:`node scripts/task444-convex-workspace-export-local-fixture-smoke.js`;`node scripts/task455-convex-export-plan-rollback-smoke.js`;Web 入口设计见 `design/03-rust-web/process/3-17-convex-export-web-entry-v1.md`。 - [ ] P7 Page Aggregate / tree command / realtime 兼容链完成阶段性瘦身,并把被替代 process 稿移入 `old/` 或 `done/`。 diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 44aca6ba..eb1e0132 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -10747,9 +10747,14 @@ fn execute_command( }), })) } - "documents.emptyTrashByWorkspace" => { + "documents.emptyTrashByWorkspace" | "tree.trash.emptyWorkspace" => { let payload: DocumentEmptyTrashCommandPayload = parse_payload(command_wire.payload.clone())?; + let command_name = if command_wire.name == "tree.trash.emptyWorkspace" { + "tree.trash.emptyWorkspace" + } else { + "documents.emptyTrashByWorkspace" + }; let stream_delta_hint = tree_resync_required_hint( "documents_empty_trash", json!({ @@ -10757,7 +10762,7 @@ fn execute_command( }), ); let command = CommandEnvelope { - name: "documents.emptyTrashByWorkspace".into(), + name: command_name.into(), command_id: command_wire.command_id.clone(), idempotency_key: command_wire.idempotency_key.clone(), actor: to_actor_payload(&command_wire.actor), diff --git a/rust/crates/mnote-web/src/hermes_tools/manifest.rs b/rust/crates/mnote-web/src/hermes_tools/manifest.rs index 43ff730b..e0cce8dc 100644 --- a/rust/crates/mnote-web/src/hermes_tools/manifest.rs +++ b/rust/crates/mnote-web/src/hermes_tools/manifest.rs @@ -27,6 +27,10 @@ pub fn manifest() -> Value { page_save_tool(), available_tool("mnote.page.update_title", "更新当前页面标题", ["page.write"]), available_tool("mnote.page.update_options", "更新当前页面设置", ["page.write"]), + mindmap_fetch_tool(), + mindmap_apply_ops_tool(), + office_fetch_summary_tool(), + office_propose_changes_tool(), available_tool("mnote.artifact.create_summary", "为当前页面创建或更新 AI Summary", ["artifact.write"]), available_tool("mnote.artifact.create_ai_note", "基于当前页面创建新的 AI Note", ["artifact.write"]) ] @@ -479,6 +483,120 @@ fn doc_markdown_edit_tool() -> Value { tool } +fn resource_identity_properties(resource_id_name: &str) -> Value { + let mut properties = base_identity_properties(); + if let Value::Object(map) = &mut properties { + map.insert("rootUri".into(), json!({ "type": "string" })); + map.insert("sourceKind".into(), json!({ "type": "string" })); + map.insert(resource_id_name.into(), json!({ "type": "string" })); + map.insert("resourcePath".into(), json!({ "type": "string" })); + map.insert( + "aiAccessScope".into(), + json!({ + "type": "object", + "properties": { + "permissionLevel": { "type": "string" }, + "allowedResourceIds": { "type": "array", "items": { "type": "string" } }, + "allowedRoots": { "type": "array" } + } + }), + ); + } + properties +} + +fn mindmap_fetch_tool() -> Value { + let mut properties = resource_identity_properties("mindmapId"); + if let Value::Object(map) = &mut properties { + map.insert( + "scope".into(), + json!({ "type": "string", "enum": ["tree", "subtree", "markdown_summary"], "default": "tree" }), + ); + map.insert("nodeId".into(), json!({ "type": "string" })); + } + json!({ + "name": "mnote.mindmap.fetch", + "description": "读取授权 root 内的 mindmap resource 结构或 markdown summary", + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": ["resource.read", "mindmap.read"], + "status": "available", + "annotations": tool_annotations(true, false, true, false), + "inputSchema": { + "type": "object", + "required": ["workspaceId", "documentId", "mindmapId", "sessionId", "runId", "toolCallId", "traceId", "actorId"], + "properties": properties + } + }) +} + +fn mindmap_apply_ops_tool() -> Value { + write_tool( + "mnote.mindmap.apply_ops", + "对授权 mindmap resource 生成或执行结构操作;本地 workspace 默认建议 agent 直接编辑授权文件,必要时才走该工具", + ["resource.write", "mindmap.write"], + json!({ + "mindmapId": { "type": "string" }, + "rootUri": { "type": "string" }, + "sourceKind": { "type": "string" }, + "resourcePath": { "type": "string" }, + "expectedRevision": { "type": ["number", "string"] }, + "ops": { + "type": "array", + "items": { "type": "object" } + }, + "aiAccessScope": { "type": "object" } + }), + ["mindmapId", "ops"], + ) +} + +fn office_fetch_summary_tool() -> Value { + let mut properties = resource_identity_properties("assetId"); + if let Value::Object(map) = &mut properties { + map.insert( + "extractMode".into(), + json!({ "type": "string", "enum": ["text", "outline", "metadata"], "default": "metadata" }), + ); + } + json!({ + "name": "mnote.office.fetch_summary", + "description": "读取授权 Office resource 的元数据和轻量文本摘要;不直接写二进制文件", + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": ["resource.read", "office.read"], + "status": "available", + "annotations": tool_annotations(true, false, true, false), + "inputSchema": { + "type": "object", + "required": ["workspaceId", "documentId", "assetId", "sessionId", "runId", "toolCallId", "traceId", "actorId"], + "properties": properties + } + }) +} + +fn office_propose_changes_tool() -> Value { + let mut properties = resource_identity_properties("assetId"); + if let Value::Object(map) = &mut properties { + map.insert("instructions".into(), json!({ "type": "string" })); + map.insert( + "basisRevision".into(), + json!({ "type": ["number", "string"] }), + ); + } + json!({ + "name": "mnote.office.propose_changes", + "description": "基于授权 Office resource 生成修改建议;真实写入仍由 OnlyOffice / officecli 完成", + "schemaVersion": TOOL_SCHEMA_VERSION, + "capabilityScope": ["resource.read", "office.read"], + "status": "available", + "annotations": tool_annotations(true, false, true, false), + "inputSchema": { + "type": "object", + "required": ["workspaceId", "documentId", "assetId", "sessionId", "runId", "toolCallId", "traceId", "actorId", "instructions"], + "properties": properties + } + }) +} + fn tool_annotations( readonly: bool, destructive: bool, diff --git a/rust/crates/mnote-web/src/hermes_tools/mod.rs b/rust/crates/mnote-web/src/hermes_tools/mod.rs index f3c4c636..30ce3a2a 100644 --- a/rust/crates/mnote-web/src/hermes_tools/mod.rs +++ b/rust/crates/mnote-web/src/hermes_tools/mod.rs @@ -3,6 +3,7 @@ pub mod block; pub mod doc; pub mod manifest; pub mod page; +pub mod resource; use serde::Deserialize; use serde_json::Value; diff --git a/rust/crates/mnote-web/src/hermes_tools/resource.rs b/rust/crates/mnote-web/src/hermes_tools/resource.rs new file mode 100644 index 00000000..c3423546 --- /dev/null +++ b/rust/crates/mnote-web/src/hermes_tools/resource.rs @@ -0,0 +1,410 @@ +use crate::context::RequestContext; +use crate::error::WebError; +use crate::hermes_tools::ToolCallInput; +use axum::http::StatusCode; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +pub async fn mindmap_fetch( + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?; + ensure_resource_scope_allowed(context, input, &target)?; + let path = target.resolve_read_path(context, input)?; + let content = fs::read_to_string(&path).map_err(|error| { + WebError::bad_request_code( + "mnote_resource_read_failed", + format!("无法读取 mindmap resource: {error}"), + ) + .with_context(context) + })?; + let data = + serde_json::from_str::(&content).unwrap_or_else(|_| json!({ "raw": content })); + let nodes = collect_mindmap_nodes(&data); + let scope = input + .arg_string("scope") + .unwrap_or_else(|| "tree".into()) + .to_ascii_lowercase(); + Ok(json!({ + "objectIdentity": target.object_identity, + "resourceKind": "mindmap", + "documentId": target.document_id, + "mindmapId": target.resource_id, + "resourcePath": target.resource_path, + "scope": scope, + "nodes": nodes, + "edges": [], + "markdownSummary": mindmap_markdown_summary(&nodes), + "revision": file_revision(&path), + "source": "local_folder" + })) +} + +pub async fn mindmap_apply_ops( + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + ensure_resource_write_contract(context, input)?; + let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?; + ensure_resource_scope_allowed(context, input, &target)?; + let path = target.resolve_write_path(context, input)?; + let ops = input.arg_value("ops").ok_or_else(|| { + WebError::bad_request_code("mnote_resource_ops_required", "mindmap 写工具缺少 ops") + .with_context(context) + })?; + if input.dry_run.unwrap_or(false) { + return Ok(json!({ + "dryRun": true, + "commandName": "mnote.mindmap.apply_ops", + "objectIdentity": target.object_identity, + "resourceKind": "mindmap", + "documentId": target.document_id, + "mindmapId": target.resource_id, + "resourcePath": target.resource_path, + "diff": [{"op": "mindmap.apply_ops", "ops": ops}] + })); + } + Err(WebError::bad_request_code( + "mnote_resource_native_patch_required", + format!( + "本地 mindmap resource 写入请使用 agent 原生 patch 编辑授权文件;已校验可写资源路径 {}", + path.display() + ), + ) + .with_context(context)) +} + +pub async fn office_fetch_summary( + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let target = ResourceToolTarget::from_input(context, input, "onlyoffice", "assetId")?; + ensure_resource_scope_allowed(context, input, &target)?; + let path = target.resolve_read_path(context, input)?; + let metadata = fs::metadata(&path).map_err(|error| { + WebError::bad_request_code( + "mnote_resource_metadata_failed", + format!("无法读取 Office resource 元数据: {error}"), + ) + .with_context(context) + })?; + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(target.resource_id.as_str()); + Ok(json!({ + "objectIdentity": target.object_identity, + "resourceKind": "only_office", + "documentId": target.document_id, + "assetId": target.resource_id, + "resourcePath": target.resource_path, + "fileName": file_name, + "mimeType": office_mime_type(&path), + "textSummary": office_text_preview(&path), + "outline": [], + "revision": file_revision(&path), + "size": metadata.len(), + "source": "local_folder" + })) +} + +pub async fn office_propose_changes( + context: &RequestContext, + input: &ToolCallInput, +) -> Result { + let target = ResourceToolTarget::from_input(context, input, "onlyoffice", "assetId")?; + ensure_resource_scope_allowed(context, input, &target)?; + let path = target.resolve_write_path(context, input)?; + let instructions = input.arg_string("instructions").ok_or_else(|| { + WebError::bad_request_code( + "mnote_resource_instructions_required", + "office 建议工具缺少 instructions", + ) + .with_context(context) + })?; + Ok(json!({ + "dryRun": input.dry_run.unwrap_or(false), + "commandName": "mnote.office.propose_changes", + "objectIdentity": target.object_identity, + "resourceKind": "only_office", + "documentId": target.document_id, + "assetId": target.resource_id, + "resourcePath": target.resource_path, + "basisRevision": input.arg_value("basisRevision"), + "revision": file_revision(&path), + "changeSummary": instructions, + "suggestedEdits": [{ + "kind": "instruction", + "text": instructions + }], + "requiresOnlyOffice": true, + "requiresOfficeCli": true, + "writesBinary": false + })) +} + +struct ResourceToolTarget { + document_id: String, + resource_id: String, + object_identity: String, + resource_path: Option, +} + +impl ResourceToolTarget { + fn from_input( + context: &RequestContext, + input: &ToolCallInput, + resource_kind: &str, + resource_id_arg: &str, + ) -> Result { + let document_id = input.effective_document_id().ok_or_else(|| { + WebError::bad_request_code( + "mnote_resource_document_required", + "资源工具缺少 documentId", + ) + .with_context(context) + })?; + let resource_id = input.arg_string(resource_id_arg).ok_or_else(|| { + WebError::bad_request_code( + "mnote_resource_id_required", + format!("资源工具缺少 {resource_id_arg}"), + ) + .with_context(context) + })?; + let object_identity = format!("resource:{resource_kind}:{document_id}:{resource_id}"); + Ok(Self { + document_id, + resource_id, + object_identity, + resource_path: input.arg_string("resourcePath"), + }) + } + + fn resolve_read_path( + &self, + context: &RequestContext, + input: &ToolCallInput, + ) -> Result { + let root_uri = local_root_uri_for_resource(input).ok_or_else(|| { + WebError::new( + StatusCode::FORBIDDEN, + "mnote_resource_root_uri_required", + "资源工具需要授权 rootUri", + ) + .with_context(context) + })?; + let relative = self.relative_path(); + crate::routes::ensure_local_path_read_access(context, &root_uri, &relative) + .map_err(|error| error.with_context(context)) + } + + fn resolve_write_path( + &self, + context: &RequestContext, + input: &ToolCallInput, + ) -> Result { + let root_uri = local_root_uri_for_resource(input).ok_or_else(|| { + WebError::new( + StatusCode::FORBIDDEN, + "mnote_resource_root_uri_required", + "资源工具需要授权 rootUri", + ) + .with_context(context) + })?; + let root = crate::routes::ensure_local_workspace_access(context, &root_uri) + .map_err(|error| error.with_context(context))?; + let relative = self.relative_path(); + let path = root.join(relative); + let canonical = path.canonicalize().map_err(|error| { + WebError::bad_request_code( + "mnote_resource_unavailable", + format!("无法访问资源文件: {error}"), + ) + .with_context(context) + })?; + if !canonical.starts_with(root) { + return Err(WebError::bad_request_code( + "mnote_resource_root_escape", + "资源工具不能越过授权目录", + ) + .with_context(context)); + } + Ok(canonical) + } + + fn relative_path(&self) -> String { + self.resource_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| self.resource_id.clone()) + } +} + +fn ensure_resource_scope_allowed( + context: &RequestContext, + input: &ToolCallInput, + target: &ResourceToolTarget, +) -> Result<(), WebError> { + let Some(scope) = input.arg_value("aiAccessScope") else { + return Ok(()); + }; + let allowed = scope + .get("allowedResourceIds") + .or_else(|| scope.get("allowed_resource_ids")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + if allowed.is_empty() + || allowed.contains(&target.resource_id) + || allowed.contains(&target.object_identity) + { + return Ok(()); + } + Err(WebError::new( + StatusCode::FORBIDDEN, + "mnote_resource_ai_scope_forbidden", + "当前 AI scope 不允许访问该资源", + ) + .with_context(context)) +} + +fn ensure_resource_write_contract( + context: &RequestContext, + input: &ToolCallInput, +) -> Result<(), WebError> { + if !input.has_idempotency_key() { + return Err(WebError::bad_request_code( + "mnote_tool_idempotency_required", + "写入型 mnote resource tool 必须携带 idempotencyKey", + ) + .with_context(context)); + } + if input.dry_run.is_none() { + return Err(WebError::bad_request_code( + "mnote_tool_dry_run_required", + "写入型 mnote resource tool 必须显式携带 dryRun", + ) + .with_context(context)); + } + if input.ai_access_scope_is_read_only() { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "mnote_tool_ai_scope_write_forbidden", + "当前 AI scope 是只读权限,禁止执行写入型 mnote resource tool", + ) + .with_context(context)); + } + Ok(()) +} + +fn local_root_uri_for_resource(input: &ToolCallInput) -> Option { + input.effective_root_uri().or_else(|| { + input + .arg_value("aiAccessScope") + .and_then(|scope| { + scope + .get("allowedRoots") + .or_else(|| scope.get("allowed_roots")) + .cloned() + }) + .and_then(|allowed_roots| { + allowed_roots.as_array().and_then(|roots| { + roots + .iter() + .filter_map(|root| { + root.get("rootUri") + .or_else(|| root.get("root_uri")) + .and_then(Value::as_str) + }) + .map(str::trim) + .find(|root_uri| !root_uri.is_empty()) + .map(ToOwned::to_owned) + }) + }) + }) +} + +fn collect_mindmap_nodes(value: &Value) -> Vec { + let mut nodes = Vec::new(); + collect_mindmap_nodes_inner(value, &mut nodes); + nodes +} + +fn collect_mindmap_nodes_inner(value: &Value, nodes: &mut Vec) { + let text = value + .pointer("/data/text") + .or_else(|| value.get("text")) + .and_then(Value::as_str) + .unwrap_or(""); + let id = value + .pointer("/data/uid") + .or_else(|| value.pointer("/data/id")) + .or_else(|| value.get("uid")) + .or_else(|| value.get("id")) + .and_then(Value::as_str) + .unwrap_or("root"); + if !text.is_empty() { + nodes.push(json!({ "id": id, "text": text })); + } + if let Some(children) = value.get("children").and_then(Value::as_array) { + for child in children { + collect_mindmap_nodes_inner(child, nodes); + } + } +} + +fn mindmap_markdown_summary(nodes: &[Value]) -> String { + nodes + .iter() + .filter_map(|node| node.get("text").and_then(Value::as_str)) + .take(20) + .map(|text| format!("- {text}")) + .collect::>() + .join("\n") +} + +fn file_revision(path: &Path) -> Value { + let Ok(metadata) = fs::metadata(path) else { + return Value::Null; + }; + let modified_ms = metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + json!(format!("{}:{}", metadata.len(), modified_ms)) +} + +fn office_mime_type(path: &Path) -> &'static str { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + { + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + _ => "application/octet-stream", + } +} + +fn office_text_preview(path: &Path) -> Value { + match fs::read_to_string(path) { + Ok(text) => json!(text.chars().take(4000).collect::()), + Err(_) => Value::Null, + } +} diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 13925b87..6fc7a8c3 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -726,8 +726,8 @@ pub async fn empty_trash( ); } let command = RuntimeCommandEnvelopeWire { - name: "documents.emptyTrashByWorkspace".into(), - command_id: format!("documents_empty_trash_{}", context.trace.request_id), + name: "tree.trash.emptyWorkspace".into(), + command_id: format!("tree_trash_empty_workspace_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), @@ -751,8 +751,11 @@ pub async fn empty_trash( "workspaceId": workspace_id, }), preflight_data: None, - reason: Some("mnote-web documents empty trash".into()), - refs: vec!["mnote-web-documents-trash".into()], + reason: Some("mnote-web tree trash empty workspace".into()), + refs: vec![ + "mnote-web-documents-trash-compat".into(), + "tree.trash.emptyWorkspace".into(), + ], dry_run: false, validate_only: false, }; @@ -769,7 +772,7 @@ pub async fn empty_trash( if let Value::Object(map) = &mut result { map.insert( "canonicalCommand".into(), - json!("documents.emptyTrashByWorkspace"), + json!("tree.trash.emptyWorkspace"), ); map.insert("compatRoute".into(), json!("/api/documents/empty-trash")); } @@ -784,8 +787,9 @@ pub async fn empty_trash( "traceId": context.trace.trace_id, "owner": "mnote-web", "meta": { - "commandName": "documents.emptyTrashByWorkspace", - "canonicalCommand": "documents.emptyTrashByWorkspace", + "commandName": "tree.trash.emptyWorkspace", + "canonicalCommand": "tree.trash.emptyWorkspace", + "compatCommandName": "documents.emptyTrashByWorkspace", "artifacts": artifacts, "artifactError": artifact_error, }, @@ -1242,11 +1246,11 @@ mod tests { assert_eq!(payload["result"]["deletedCount"], 2); assert_eq!( payload["result"]["canonicalCommand"], - "documents.emptyTrashByWorkspace" + "tree.trash.emptyWorkspace" ); assert_eq!( payload["meta"]["artifacts"]["commandLog"]["commandName"], - "documents.emptyTrashByWorkspace" + "tree.trash.emptyWorkspace" ); assert_eq!( payload["meta"]["artifacts"]["domainEvent"]["eventType"], diff --git a/rust/crates/mnote-web/src/routes/hermes_tools.rs b/rust/crates/mnote-web/src/routes/hermes_tools.rs index 44bb9df2..8502fcd7 100644 --- a/rust/crates/mnote-web/src/routes/hermes_tools.rs +++ b/rust/crates/mnote-web/src/routes/hermes_tools.rs @@ -2,7 +2,7 @@ use super::hermes_client; use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::hermes_tools::{artifact, block, doc, manifest, page, ToolCallInput}; +use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput}; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::Json; @@ -344,6 +344,10 @@ pub(crate) async fn execute_mnote_tool_call( "mnote.page.save" => page::page_save(&state, &context, &input).await, "mnote.page.update_title" => page::update_title(&state, &context, &input).await, "mnote.page.update_options" => page::update_options(&state, &context, &input).await, + "mnote.mindmap.fetch" => resource::mindmap_fetch(&context, &input).await, + "mnote.mindmap.apply_ops" => resource::mindmap_apply_ops(&context, &input).await, + "mnote.office.fetch_summary" => resource::office_fetch_summary(&context, &input).await, + "mnote.office.propose_changes" => resource::office_propose_changes(&context, &input).await, "mnote.artifact.create_summary" => artifact::create_summary(&state, &context, &input).await, "mnote.artifact.create_ai_note" => artifact::create_ai_note(&state, &context, &input).await, _ => Err( @@ -448,7 +452,13 @@ pub(crate) async fn execute_mnote_tool_call( fn is_read_tool(tool_name: &str) -> bool { matches!( tool_name, - "mnote.page.get" | "mnote.doc.fetch" | "mnote.doc.find" | "mnote.block.fetch" + "mnote.page.get" + | "mnote.doc.fetch" + | "mnote.doc.find" + | "mnote.block.fetch" + | "mnote.mindmap.fetch" + | "mnote.office.fetch_summary" + | "mnote.office.propose_changes" ) } @@ -995,6 +1005,18 @@ mod tests { assert!(tools .iter() .any(|tool| tool["name"] == "mnote.doc.apply_block_ops")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.mindmap.fetch")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.mindmap.apply_ops")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.office.fetch_summary")); + assert!(tools + .iter() + .any(|tool| tool["name"] == "mnote.office.propose_changes")); let page_save = tools .iter() .find(|tool| tool["name"] == "mnote.page.save") @@ -1148,6 +1170,324 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn hermes_tools_resource_tools_require_allowed_resource_scope() { + let root = + std::env::temp_dir().join(format!("mnote-resource-scope-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("maps")).expect("maps"); + fs::write( + root.join("maps").join("idea.mindmap.json"), + r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#, + ) + .expect("mindmap"); + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_1", &root_uri, + ) + .expect("workspace"); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.mindmap.fetch", + "workspaceId": "local-ws-resource", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "actorId": "user_1", + "sessionId": "sess_resource_scope", + "runId": "run_resource_scope", + "toolCallId": "call_resource_scope", + "traceId": "trace_resource_scope", + "args": { + "mindmapId": "mind_allowed", + "resourcePath": "maps/idea.mindmap.json", + "aiAccessScope": { + "permissionLevel": "shared_read", + "allowedResourceIds": ["mind_other"] + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("mnote_resource_ai_scope_forbidden") + ); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() { + let root = std::env::temp_dir().join(format!( + "mnote-resource-mindmap-fetch-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("maps")).expect("maps"); + fs::write( + root.join("maps").join("idea.mindmap.json"), + r#"{"data":{"text":"中心主题","uid":"root"},"children":[{"data":{"text":"分支一","uid":"child_1"},"children":[]}]}"#, + ) + .expect("mindmap"); + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_1", &root_uri, + ) + .expect("workspace"); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.mindmap.fetch", + "workspaceId": "local-ws-resource", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "actorId": "user_1", + "sessionId": "sess_mindmap_fetch", + "runId": "run_mindmap_fetch", + "toolCallId": "call_mindmap_fetch", + "traceId": "trace_mindmap_fetch", + "args": { + "mindmapId": "mind_allowed", + "resourcePath": "maps/idea.mindmap.json", + "aiAccessScope": { + "permissionLevel": "shared_read", + "allowedResourceIds": ["mind_allowed"] + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["toolName"], "mnote.mindmap.fetch"); + assert_eq!(payload["audit"]["effect"], "read"); + assert_eq!(payload["result"]["resourceKind"], "mindmap"); + assert_eq!( + payload["result"]["objectIdentity"], + "resource:mindmap:local-md:README.md:mind_allowed" + ); + assert!(payload["result"]["markdownSummary"] + .as_str() + .unwrap_or_default() + .contains("中心主题")); + assert_eq!(payload["result"]["nodes"][1]["text"], "分支一"); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn hermes_tools_mindmap_apply_ops_shared_read_is_forbidden() { + let root = std::env::temp_dir().join(format!( + "mnote-resource-mindmap-write-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("maps")).expect("maps"); + fs::write( + root.join("maps").join("idea.mindmap.json"), + r#"{"data":{"text":"中心主题","uid":"root"},"children":[]}"#, + ) + .expect("mindmap"); + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_1", &root_uri, + ) + .expect("workspace"); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.mindmap.apply_ops", + "workspaceId": "local-ws-resource", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "actorId": "user_1", + "sessionId": "sess_mindmap_write", + "runId": "run_mindmap_write", + "toolCallId": "call_mindmap_write", + "traceId": "trace_mindmap_write", + "idempotencyKey": "idem_mindmap_write", + "dryRun": false, + "args": { + "mindmapId": "mind_allowed", + "resourcePath": "maps/idea.mindmap.json", + "ops": [{"op": "update_node", "nodeId": "root", "text": "改名"}], + "aiAccessScope": { + "permissionLevel": "shared_read", + "allowedResourceIds": ["mind_allowed"] + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("mnote_tool_shared_read_write_forbidden") + ); + let _ = fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn hermes_tools_office_fetch_and_propose_changes_do_not_write_file() { + let root = std::env::temp_dir().join(format!( + "mnote-resource-office-fetch-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("office")).expect("office"); + fs::write(root.join("office").join("report.docx"), "Office text").expect("office file"); + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_1", &root_uri, + ) + .expect("workspace"); + + let fetch = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.office.fetch_summary", + "workspaceId": "local-ws-resource", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "actorId": "user_1", + "sessionId": "sess_office_fetch", + "runId": "run_office_fetch", + "toolCallId": "call_office_fetch", + "traceId": "trace_office_fetch", + "args": { + "assetId": "asset_report", + "resourcePath": "office/report.docx", + "aiAccessScope": { + "permissionLevel": "shared_read", + "allowedResourceIds": ["resource:onlyoffice:local-md:README.md:asset_report"] + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(fetch.status(), StatusCode::OK); + let fetch_body = to_bytes(fetch.into_body(), usize::MAX) + .await + .expect("fetch body"); + let fetch_payload: Value = serde_json::from_slice(&fetch_body).expect("fetch json"); + assert_eq!(fetch_payload["audit"]["effect"], "read"); + assert_eq!(fetch_payload["result"]["resourceKind"], "only_office"); + assert_eq!(fetch_payload["result"]["fileName"], "report.docx"); + + let before = fs::read(root.join("office").join("report.docx")).expect("before"); + let propose = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/tools/mnote/call") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "toolName": "mnote.office.propose_changes", + "workspaceId": "local-ws-resource", + "documentId": "local-md:README.md", + "sourceKind": "local_folder", + "rootUri": root_uri, + "actorId": "user_1", + "sessionId": "sess_office_propose", + "runId": "run_office_propose", + "toolCallId": "call_office_propose", + "traceId": "trace_office_propose", + "args": { + "assetId": "asset_report", + "resourcePath": "office/report.docx", + "instructions": "补充结论", + "aiAccessScope": { + "permissionLevel": "shared_read", + "allowedResourceIds": ["asset_report"] + } + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(propose.status(), StatusCode::OK); + let propose_body = to_bytes(propose.into_body(), usize::MAX) + .await + .expect("propose body"); + let propose_payload: Value = serde_json::from_slice(&propose_body).expect("propose json"); + assert_eq!(propose_payload["audit"]["effect"], "read"); + assert_eq!(propose_payload["result"]["writesBinary"], false); + assert_eq!( + fs::read(root.join("office").join("report.docx")).expect("after"), + before + ); + let _ = fs::remove_dir_all(&root); + } + #[tokio::test] async fn hermes_tools_page_get_accepts_delegated_actor_from_hermes_payload() { let response = app() diff --git a/rust/crates/mnote-web/src/routes/resource_trash.rs b/rust/crates/mnote-web/src/routes/resource_trash.rs index a3b4a36d..d7b0c6f7 100644 --- a/rust/crates/mnote-web/src/routes/resource_trash.rs +++ b/rust/crates/mnote-web/src/routes/resource_trash.rs @@ -30,6 +30,7 @@ pub struct MediaBatchRequest { #[serde(default)] pub asset_ids: Vec, pub new_name: Option, + pub target_document_id: Option, } #[derive(Debug, Deserialize)] @@ -510,10 +511,10 @@ pub async fn media_batch( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await; let action = body.action.trim(); - if action != "restore" && action != "delete" && action != "rename" { + if action != "restore" && action != "delete" && action != "rename" && action != "move" { return Err(WebError::bad_request_code( "media_batch_action_unsupported", - "Rust resource trash 当前仅支持附件 delete / restore / rename", + "Rust resource trash 当前仅支持附件 delete / restore / rename / move", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); @@ -553,6 +554,21 @@ pub async fn media_batch( "newName": new_name, }), ) + } else if action == "move" { + let target_document_id = require_id( + &context, + body.target_document_id.as_deref().unwrap_or_default(), + "targetDocumentId", + )?; + ( + "tree.resource.move", + json!({ + "resourceKind": "file", + "assetId": asset_id, + "fromDocumentId": document_id, + "targetDocumentId": target_document_id, + }), + ) } else { ( "tree.resource.archive", @@ -595,6 +611,29 @@ pub async fn media_batch( ) .await?; } + if action == "move" { + let target_document_id = require_id( + &context, + body.target_document_id.as_deref().unwrap_or_default(), + "targetDocumentId", + )?; + execute_convex_mutation_by_name( + state.config(), + &context, + "mediaAssets:patchById", + json!({ + "userId": user_id, + "id": asset_id, + "patch": { + "document_id": target_document_id, + }, + }), + workspace_id.as_deref(), + None, + "media_batch_move_patch", + ) + .await?; + } let command_result = execute_runtime_command_via_convex_with_artifacts( &state, &context, @@ -602,12 +641,13 @@ pub async fn media_batch( command, ) .await; - if action == "rename" { + if action == "rename" || action == "move" { if let Err(error) = command_result { tracing::warn!( error = %error.message(), asset_id = %asset_id, - "tree.resource.rename artifact command 失败,已保留兼容 patch 结果" + action = %action, + "tree.resource artifact command 失败,已保留兼容 patch 结果" ); } } else { @@ -624,10 +664,13 @@ pub async fn media_batch( "restored": if action == "restore" { updated } else { 0 }, "deleted": if action == "delete" { updated } else { 0 }, "renamed": if action == "rename" { updated } else { 0 }, + "moved": if action == "move" { updated } else { 0 }, "canonicalCommand": if action == "restore" { "tree.resource.restore" } else if action == "rename" { "tree.resource.rename" + } else if action == "move" { + "tree.resource.move" } else { "tree.resource.archive" }, @@ -1195,6 +1238,19 @@ mod tests { assert_ne!(renamed["result"]["canonicalCommand"], "tree.node.rename"); } + #[tokio::test] + async fn resource_move_uses_resource_command_not_document_command() { + let moved = post_json( + "/api/media/batch", + json!({"action": "move", "assetIds": ["asset_1"], "targetDocumentId": "doc_2"}), + ) + .await; + + assert_eq!(moved["result"]["moved"], 1); + assert_eq!(moved["result"]["canonicalCommand"], "tree.resource.move"); + assert_ne!(moved["result"]["canonicalCommand"], "documents.move"); + } + #[tokio::test] async fn mindmap_trash_routes_delete_restore_purge_and_empty() { let deleted = delete_json("/api/mindmap/doc_1/mind_1").await; diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 2ea31345..de98f24a 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -1023,6 +1023,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { return textToTiptapDocument(fallbackText); }; + const pageBodyTiptapDocument = (body, fallbackText = '') => { + const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null; + if (blockDocument) return toTiptapDocument(blockDocument, fallbackText); + return toTiptapDocument(body?.content, fallbackText); + }; + const inlineTextNodes = (node) => { if (!node || typeof node !== 'object') return []; if (Array.isArray(node.content)) { @@ -1568,7 +1574,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const aggregatePlainText = (aggregate) => { const body = aggregate?.body || {}; - return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim(); + return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim(); }; const conflictEnvelopeFromResponse = (payload) => ( @@ -1604,7 +1610,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const nextBody = nextAggregate?.body || {}; const nextPermissions = nextAggregate?.head?.permissions || {}; const nextConflictKey = conflictDetectionKeyFromBody(nextBody); - const nextTiptapDocument = toTiptapDocument(nextBody.content); + const nextTiptapDocument = pageBodyTiptapDocument(nextBody); const nextSerialized = JSON.stringify(nextTiptapDocument); const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey); session.latestAggregate = nextAggregate; @@ -1971,7 +1977,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const nextBody = nextAggregate?.body || {}; const nextPermissions = nextAggregate?.head?.permissions || {}; const nextConflictKey = conflictDetectionKeyFromBody(nextBody); - const nextTiptapDocument = toTiptapDocument(nextBody.content); + const nextTiptapDocument = pageBodyTiptapDocument(nextBody); const nextSerialized = JSON.stringify(nextTiptapDocument); const contentChanged = nextSerialized !== session.currentSerialized; session.externalChangePending = false; @@ -2250,7 +2256,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { const conflictDetectionKey = conflictDetectionKeyFromBody(pageBody); const pageBodyRevision = Number.isInteger(pageBody.revision) ? pageBody.revision : null; const keyRevision = revisionFromConflictKey(conflictDetectionKey); - const tiptapDocument = toTiptapDocument(pageBody.content); + const tiptapDocument = pageBodyTiptapDocument(pageBody); const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap); const session = { key: buildDocumentSessionKey(runtimeDescriptor.bootstrap), @@ -3492,6 +3498,10 @@ mod tests { assert!(html.contains("/api/tree/events")); assert!(html.contains("data-mnote-tree-live-transport")); assert!(html.contains("syncPageAggregateScript(session, nextAggregate);")); + assert!(html.contains("const pageBodyTiptapDocument = (body, fallbackText = '') => {")); + assert!(html.contains("body?.blockDocument || body?.block_document")); + assert!(html.contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody);")); + assert!(html.contains("const tiptapDocument = pageBodyTiptapDocument(pageBody);")); assert!(!html.contains("mnote-web-document-shell")); } diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 9fd21418..37959c35 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -8072,6 +8072,15 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##" } } + function liveDeltaNeedsResync(payload) { + var data = payload && (payload.data || payload.delta || payload); + var op = data && typeof data.op === 'string' ? String(data.op).trim() : ''; + if (!op || op === 'noop') return false; + if (op === 'resync_required') return true; + if (data && data.requiresResync === true) return true; + return false; + } + function startWithSse(bootstrap, workspaceId, url) { var failures = 0; var source = new EventSource(url.toString()); @@ -8144,8 +8153,7 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##" } else if (kind === 'delta') { document.documentElement.setAttribute('data-mnote-tree-live-revision', String(revision)); dispatchTreeEvent('tree:delta', { payload: payload, revision: revision, bootstrap: bootstrap }); - // Delta indicates something changed; request fresh resync from server - if (ws.readyState === WebSocket.OPEN) { + if (liveDeltaNeedsResync(payload) && ws.readyState === WebSocket.OPEN) { ws.send('resync'); } } else if (kind === 'resync') { @@ -8738,7 +8746,10 @@ mod tests { assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot")); assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta")); assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync")); + assert!(TREE_LIVE_CONTROLLER_JS.contains("liveDeltaNeedsResync(payload)")); + assert!(TREE_LIVE_CONTROLLER_JS.contains("op === 'resync_required'")); assert!(!TREE_LIVE_CONTROLLER_JS.contains("resyncEndpoint")); assert!(!TREE_LIVE_CONTROLLER_JS.contains("tree:resync-requested")); + assert!(!TREE_LIVE_CONTROLLER_JS.contains("Delta indicates something changed")); } } diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index d380bec7..55e2d0b3 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -439,6 +439,7 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { "tree.resource.archive" | "tree.resource.restore" | "tree.resource.rename" + | "tree.resource.move" | "tree.resource.purge" ) { args = convex_resource_lifecycle_args_for_plan(plan, &args); @@ -537,6 +538,17 @@ fn convex_resource_lifecycle_args_for_plan( "file_name": args.get("newName").and_then(Value::as_str).unwrap_or_default(), }, }), + "move" => json!({ + "userId": user_id, + "id": id, + "patch": { + "document_id": args + .get("targetDocumentId") + .or_else(|| args.get("target_document_id")) + .and_then(Value::as_str) + .unwrap_or_default(), + }, + }), "purge" => json!({ "userId": user_id, "id": id, diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index 2ec75015..d347b036 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -39,6 +39,7 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { "tree.node.archive" => "documents:softDelete", "tree.node.restore" => "documents:restore", "tree.node.purge" => "documents:purge", + "tree.trash.emptyWorkspace" => "documents:emptyTrashByWorkspace", "tree.subtree.move" => "documents:move", "tree.subtree.copy" => "documents:copyTree", "tree.resource.copy" => "mediaAssets:batchCopy", diff --git a/scripts/task456-resource-object-shell-sync-smoke.js b/scripts/task456-resource-object-shell-sync-smoke.js new file mode 100644 index 00000000..15dc028d --- /dev/null +++ b/scripts/task456-resource-object-shell-sync-smoke.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { + BASE_URL, + UI_TIMEOUT_MS, + assert, + cleanupDocuments, + createTempDocument, + ensureAuthenticated, + openDocument, + openFilesystemView, + renameDocument, + requestJson, +} = require("./tree-shell-smoke-helpers"); + +const TASK = "task456-resource-object-shell-sync-smoke"; +const OUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUT_DIR, "result.json"); + +async function writeResult(result) { + await fs.mkdir(OUT_DIR, { recursive: true }); + await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...result, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8"); +} + +async function createMindmap(request, workspaceId, documentId, mindmapId, title) { + return await requestJson(request, `/api/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, { + method: "POST", + data: { + commandName: "mindmaps.put", + workspaceId, + createOnly: true, + data: { data: { text: title, uid: "root" }, children: [] }, + }, + }); +} + +async function readResourceState(page, documentId, mindmapId, officeAssetId) { + return await page.evaluate( + ({ documentId: docId, mindmapId: mapId, officeId }) => { + const fileRoot = document.getElementById("sidebar-file-tree-root"); + const mindmapRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(mapId)}"]`); + const officeRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(officeId)}"]`); + const pageRow = document.querySelector(`#sidebar-file-tree-root .tree-row[data-row-id="doc:${CSS.escape(docId)}"]`); + const mindmapTitle = mindmapRow?.querySelector(":scope > .tree-link > .tree-link-title")?.textContent || ""; + const officeTitle = officeRow?.querySelector(":scope > .tree-link > .tree-link-title")?.textContent || ""; + return { + fileRootStable: Boolean(fileRoot && fileRoot === window.__task456FileRoot), + pageExists: pageRow instanceof HTMLElement, + mindmapExists: mindmapRow instanceof HTMLElement, + officeExists: officeRow instanceof HTMLElement, + mindmapSelected: mindmapRow instanceof HTMLElement ? mindmapRow.getAttribute("data-selected") || "" : "", + officeSelected: officeRow instanceof HTMLElement ? officeRow.getAttribute("data-selected") || "" : "", + mindmapTitle: mindmapTitle.trim(), + officeTitle: officeTitle.trim(), + mindmapObjectIdentity: mindmapRow instanceof HTMLElement ? mindmapRow.dataset.objectIdentity || "" : "", + officeObjectIdentity: officeRow instanceof HTMLElement ? officeRow.dataset.objectIdentity || "" : "", + currentObjectEditor: document.querySelector("[data-mnote-object-editor]")?.getAttribute("data-mnote-object-editor") || "", + currentObjectIdentity: document.querySelector("[data-mnote-object-identity]")?.getAttribute("data-mnote-object-identity") || "", + }; + }, + { documentId, mindmapId, officeAssetId }, + ); +} + +async function injectOfficeRow(page, documentId, assetId) { + await page.evaluate( + ({ docId, officeId }) => { + const root = document.getElementById("sidebar-file-tree-root"); + if (!(root instanceof HTMLElement)) throw new Error("缺少 file tree root"); + if (document.querySelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${CSS.escape(officeId)}"]`)) return; + const row = document.createElement("div"); + row.className = "tree-row"; + row.dataset.testid = "filetree-asset-row"; + row.dataset.assetId = officeId; + row.dataset.objectKind = "onlyoffice"; + row.dataset.objectIdentity = JSON.stringify({ objectKind: "onlyoffice", documentId: docId, assetId: officeId }); + row.innerHTML = ``; + row.querySelector("button").addEventListener("click", () => { + window.location.href = `/office/${encodeURIComponent(docId)}/${encodeURIComponent(officeId)}?fileName=task456-office.docx&fileType=docx&mode=edit`; + }); + root.appendChild(row); + }, + { docId: documentId, officeId: assetId }, + ); +} + +(async () => { + await fs.mkdir(OUT_DIR, { recursive: true }); + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" }); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + const createdDocuments = []; + const result = { + baseUrl: BASE_URL, + fixture: {}, + before: null, + afterMindmapOpen: null, + afterMindmapUpdate: null, + afterOfficeOpen: null, + failures: [], + }; + + try { + await ensureAuthenticated(page, context.request); + const doc = await createTempDocument(context.request, null); + createdDocuments.push(doc.documentId); + const stamp = Date.now().toString().slice(-8); + const title = `TEST-456-resource-${stamp}`; + await renameDocument(context.request, doc.workspaceId, doc.documentId, title); + const mindmapId = `mindmap_456_${stamp}`; + const officeAssetId = `asset_office_456_${stamp}`; + await createMindmap(context.request, doc.workspaceId, doc.documentId, mindmapId, `TEST-456-mind-${stamp}`); + result.fixture = { ...doc, title, mindmapId, officeAssetId }; + + await openDocument(page, doc.workspaceId, doc.documentId); + await openFilesystemView(page); + await page.waitForSelector(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"]`, { timeout: UI_TIMEOUT_MS }); + await injectOfficeRow(page, doc.documentId, officeAssetId); + await page.evaluate(() => { + window.__task456FileRoot = document.getElementById("sidebar-file-tree-root"); + }); + result.before = await readResourceState(page, doc.documentId, mindmapId, officeAssetId); + assert(result.before.mindmapExists, "filetree 应显示 mindmap 资源行"); + assert(result.before.officeExists, "filetree 应显示 office 资源行"); + + await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${mindmapId}"] .tree-link`, { timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname.includes(`/mindmap/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(mindmapId)}`), { + timeout: UI_TIMEOUT_MS, + }); + await page.waitForSelector("[data-mnote-object-editor='mindmap']", { timeout: UI_TIMEOUT_MS }); + result.afterMindmapOpen = await readResourceState(page, doc.documentId, mindmapId, officeAssetId); + assert(result.afterMindmapOpen.fileRootStable, "打开 mindmap 后 filetree root 不应被替换"); + assert(result.afterMindmapOpen.currentObjectIdentity.includes(`resource:mindmap:${doc.documentId}:${mindmapId}`), "mindmap 对象壳应暴露 resource identity"); + + await requestJson(context.request, `/api/mindmap/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(mindmapId)}`, { + method: "POST", + data: { + commandName: "mindmap.command.apply", + workspaceId: doc.workspaceId, + commands: [{ type: "updateText", mindmapId, nodeId: "root", text: `TEST-456-updated-${stamp}` }], + projectionRevision: 1, + }, + }); + await openDocument(page, doc.workspaceId, doc.documentId); + await openFilesystemView(page); + await injectOfficeRow(page, doc.documentId, officeAssetId); + result.afterMindmapUpdate = await readResourceState(page, doc.documentId, mindmapId, officeAssetId); + assert(result.afterMindmapUpdate.mindmapExists, "mindmap 更新后 filetree 资源行不应丢失"); + assert(result.afterMindmapUpdate.pageExists, "mindmap 更新后页面行不应丢失"); + + await page.click(`#sidebar-file-tree-root .tree-row[data-asset-id="${officeAssetId}"] .tree-link`, { timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.pathname.includes(`/office/${encodeURIComponent(doc.documentId)}/${encodeURIComponent(officeAssetId)}`), { + timeout: UI_TIMEOUT_MS, + }); + await page.waitForSelector("[data-mnote-object-editor='onlyoffice']", { timeout: UI_TIMEOUT_MS }); + result.afterOfficeOpen = await readResourceState(page, doc.documentId, mindmapId, officeAssetId); + assert(result.afterOfficeOpen.currentObjectIdentity.includes(`resource:onlyoffice:${doc.documentId}:${officeAssetId}`), "office 对象壳应暴露 resource identity"); + + await writeResult({ ...result, ok: true, finalUrl: page.url() }); + console.log(`ok ${TASK} ${RESULT_PATH}`); + } catch (error) { + await writeResult({ + ...result, + ok: false, + currentUrl: page.url(), + error: error instanceof Error ? error.stack || error.message : String(error), + }); + throw error; + } finally { + if (createdDocuments.length) await cleanupDocuments(context.request, createdDocuments).catch(() => null); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +})().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : error); + process.exit(1); +});