feat: advance local-first workspace checklist
- add admin access-policy UI and local access control surfaces - add local markdown conflict resolution UI and smoke coverage - add ACP local agent changed-files audit scaffold and read-only write guard - document current P0-P2 checklist progress and verification evidence
This commit is contained in:
+363
@@ -0,0 +1,363 @@
|
||||
# 1-3 [process] 当前主线持续推进 checklist v1
|
||||
|
||||
> 创建时间:2026-05-19
|
||||
>
|
||||
> 当前状态:`PROCESS`
|
||||
>
|
||||
> 上位依据:
|
||||
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
|
||||
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-3-local-workspace-access-control-productization-v1.md`
|
||||
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
|
||||
>
|
||||
> 目标:把 `01-05 当前主线与优先级总览` 转成可持续推进、可验证、可迁移到 `done/` 的执行清单。
|
||||
|
||||
---
|
||||
|
||||
## 0. 总原则
|
||||
|
||||
- [x] 产品形态固定为 `VSCode 简化版工作区内核 + tiptap markdown 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面`。
|
||||
- [x] 本地文件夹是默认数据真相;Rust kernel 是唯一语义真相。
|
||||
- [x] Convex / 服务端降级为账号、分享、同步、协作和 AI 隔离控制面。
|
||||
- [x] AI 默认尽量使用 agent 原生文件读写、diff、patch 能力;MNote 只提供授权 root、页面定位、必要元数据和特殊资源工具。
|
||||
- [ ] 每完成一个阶段后,把对应过程稿移动到该分类 `done/`,并在本 checklist 写入验证证据。
|
||||
|
||||
---
|
||||
|
||||
## 1. P0 管理员目录授权控制面
|
||||
|
||||
对应 `01-05` 执行顺序:`1. 管理员目录授权 UI / API`。
|
||||
|
||||
### 1.1 后端 API 与权限底座
|
||||
|
||||
- [x] 管理员身份支持 `MNOTE_ADMIN_USER_IDS`。
|
||||
- [x] access policy 默认路径固定为 `/mnt/Data1T/Mnote_data/control-plane/access-policy.json`。
|
||||
- [x] 管理员可读写任意本地目录,普通用户只能访问 owner / grant 授权目录。
|
||||
- [x] read grant 只能读,write grant 可写。
|
||||
- [x] 新增 `GET /api/admin/access-policy`。
|
||||
- [x] 新增 `POST /api/admin/access-policy/validate-root`。
|
||||
- [x] 新增 `POST /api/admin/access-policy/grants`。
|
||||
- [x] 新增 `DELETE /api/admin/access-policy/grants/{grantId}`。
|
||||
- [x] 全入口权限审计完成:local folder、page body、tree command、Hermes / Reasonix、shared AI session。
|
||||
|
||||
验证证据:
|
||||
|
||||
- [x] `cargo test -p mnote-web local_access_policy -- --nocapture`
|
||||
- [x] `cargo test -p mnote-web local_workspace_access -- --nocapture`
|
||||
- [x] `cargo test -p mnote-web local_folder -- --nocapture`
|
||||
- [x] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture`
|
||||
- [x] `cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture`
|
||||
|
||||
### 1.2 管理员 UI
|
||||
|
||||
目标:让管理员不用手写 JSON 就能管理用户目录授权。
|
||||
|
||||
- [x] 定位当前 Rust SSR 设置 / 管理入口,确认管理员页面应挂在 `rust/crates/mnote-web/src/ssr/pages/` 的哪个壳内。
|
||||
- [x] 增加管理员访问入口:非管理员不可见,管理员可进入本地目录授权管理页。
|
||||
- 实现:`/admin/access-policy` + `PageLayout` 管理员 quick action。
|
||||
- [x] 增加 policy 列表:显示 `policyPath`、env admins、policy admins、grant 列表。
|
||||
- 实现:`rust/crates/mnote-web/src/ssr/pages/admin.rs` 调用 `GET /api/admin/access-policy` 并展示 JSON。
|
||||
- [x] 增加 validate root 表单:输入 `rootPath` 或 `rootUri` 后显示 canonical path / rootUri。
|
||||
- [x] 增加 create grant 表单:`userId`、`permission`、`recursive`、`capabilities`。
|
||||
- [x] 增加 delete grant 操作:删除前显示授权目录和用户,删除后刷新列表。
|
||||
- [x] 增加 UI 错误态:未登录、非管理员、目录不存在、重复授权、无效 capability。
|
||||
- 实现:页面展示 API 错误;SSR route 对非管理员返回 403;API 保持后端错误码。
|
||||
- [x] 补管理员 UI smoke:真实登录管理员可新增 read grant,普通用户可读不可写。
|
||||
- 验证:`node scripts/task450-admin-access-policy-ui-smoke.js`。
|
||||
- [x] 补普通用户 smoke:普通用户无法打开管理员授权页,直接调用 API 返回 403。
|
||||
- 验证:`cargo test -p mnote-web admin_access_policy -- --nocapture`。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [x] `cargo test -p mnote-web local_access_policy -- --nocapture`
|
||||
- [x] `cargo test -p mnote-web local_workspace_access -- --nocapture`
|
||||
- [x] `node scripts/task450-admin-access-policy-ui-smoke.js`
|
||||
|
||||
---
|
||||
|
||||
## 2. P1 VSCode-like 冲突处理 UI
|
||||
|
||||
对应 `01-05` 执行顺序:`2. VSCode-like 冲突处理 UI`。
|
||||
|
||||
目标:tiptap 前端保存与 agent 后台写文件同时发生时,用户可以像 VSCode 一样看见冲突、选择磁盘版本、保留编辑器版本或打开 diff 合并。
|
||||
|
||||
### 2.1 冲突模型收口
|
||||
|
||||
- [x] 盘点现有 `fileVersion` / `conflictDetectionKey` 的生成、传递和校验路径。
|
||||
- 结论:本地 markdown aggregate 已暴露 `fileVersion` alias;`documents/save` compat 会把 `expectedFileVersion` / `conflictDetectionKey` 收敛到 `PageBodyWriteRequest.expected_file_version`;`/api/page-body/write` 只接受 `expectedFileVersion`,避免 alias 重复。
|
||||
- [x] 确认所有本地 markdown 写入口都携带 expected file version:tiptap 保存、`mnote.doc.markdown_edit`、`mnote.page.save`、documents compat save。
|
||||
- 实现:tiptap local-folder 保存改走 `/api/page-body/write` 并只传 `expectedFileVersion`;`mnote.doc.markdown_edit` 从当前 aggregate 的 `fileVersion` / `conflictDetectionKey` 取 expected version;`mnote.page.save` 读取 tool 入参 `expectedFileVersion`;documents compat save 继续兼容旧 `conflictDetectionKey`。
|
||||
- [ ] 统一冲突错误 envelope:错误码、当前磁盘版本、编辑器基线版本、documentId、rootUri、建议动作。
|
||||
- [x] 让冲突错误不丢失当前编辑器内容,前端可以继续保留未保存 buffer。
|
||||
- 实现:冲突态优先从当前挂载的 ProseMirror DOM 捕获文本;“保留当前编辑器版本”会用最新 `fileVersion` 重新提交当前 buffer。
|
||||
|
||||
### 2.2 冲突交互
|
||||
|
||||
- [x] 设计冲突 modal / side panel:显示当前编辑器版本、磁盘版本、文件路径、最后修改来源。
|
||||
- 实现:文档页内嵌 `mnote-editor-conflict-panel`,展示文件标识、来源为本地文件变更,并提供磁盘 / 当前 / diff 三个动作。
|
||||
- [x] 实现“接受磁盘版本”:重新读取文件,替换编辑器 buffer。
|
||||
- [x] 实现“保留编辑器版本”:用最新 fileVersion 重新提交当前编辑器内容。
|
||||
- [x] 实现“打开 diff”:支持 markdown 文本并排查看;复杂块结构先降级为 markdown 文本。
|
||||
- [ ] 实现“合并”:支持从 diff 面板选择合并结果并写回。
|
||||
- [ ] 合并完成后写回本地 markdown,并刷新 page aggregate / file tree snapshot。
|
||||
- [ ] AI 写入导致冲突时,提示来源为 agent run,而不是普通外部修改。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [x] 新增单测:stale `expectedFileVersion` 被拒绝。
|
||||
- 验证:`cargo test -p mnote-web local_folder_documents_save_rejects_stale_expected_file_version -- --nocapture`
|
||||
- [x] 新增 browser smoke:浏览器打开页面后外部修改同一 `.md` 文件,保存时出现冲突 UI。
|
||||
- 验证:`node scripts/task451-local-markdown-conflict-resolution-ui-smoke.js`
|
||||
- [ ] 新增 browser smoke:agent 修改同一 `.md` 文件后,tiptap 保存触发冲突 UI。
|
||||
|
||||
补充验证:
|
||||
|
||||
- [x] `cargo fmt --check --all`
|
||||
- [x] `cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`
|
||||
- [x] `cargo test -p mnote-web local_folder -- --nocapture`
|
||||
- [x] `npm run check:local-first-convex-guard`
|
||||
|
||||
---
|
||||
|
||||
## 3. P2 Agent changed files / diff 审计
|
||||
|
||||
对应 `01-05` 执行顺序:`3. agent changed files / diff 审计`。
|
||||
|
||||
目标:agent 可以像在 VSCode 里一样直接改授权目录文件,但 MNote 要记录“谁、在哪个 root、通过哪个 run、改了哪些文件、diff 摘要是什么”。
|
||||
|
||||
### 3.1 审计事件模型
|
||||
|
||||
- [x] 设计本地审计目录:建议放在 `/mnt/Data1T/Mnote_data/control-plane/agent-audit/`。
|
||||
- [x] 定义审计事件 JSONL 字段:`eventId`、`actorId`、`agentKind`、`runId`、`rootUri`、`permission`、`changedFiles`、`diffSummary`、`createdAt`。
|
||||
- [ ] 区分 agent 原生文件修改与 MNote tool 写入:二者都要能归入同一个 run audit。
|
||||
- [ ] 对只读 grant 的 agent run 写入尝试记录拒绝事件。
|
||||
- 当前已补齐工具层只读拒绝:`mnote.doc.markdown_edit` / `mnote.page.save` / `mnote.block.*` 在 `read_only` AI scope 下直接拒绝写入;待 run 结束审计事件也记录 `writeAttemptRejected` 后再勾选。
|
||||
|
||||
### 3.2 写入采集
|
||||
|
||||
- [x] 在 Hermes / Reasonix run 启动前记录 root snapshot:文件 mtime、size、hash。
|
||||
- [x] run 结束后对比 root snapshot,生成 changed files。
|
||||
- [x] 对 markdown 文件生成简短 diff summary;大文件只记录 hash / size / path。
|
||||
- [x] 把审计事件落盘到 control-plane,不写进用户正文目录。
|
||||
- [ ] 在 AI 会话 UI 显示 changed files 列表,并可展开查看 diff 摘要。
|
||||
- 已接入:`run.completed.agentAudit.changedFiles` 会进入 `agent.changed_files` 工具卡并可展开查看;待真实浏览器 AI run smoke 验证后勾选。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [x] 单测:run 前后文件变化可生成 changed files。
|
||||
- 验证:`cargo test -p mnote-web local_agent_audit_snapshot_detects_changed_files -- --nocapture`
|
||||
- [ ] 单测:只读授权下写入被拒绝并产生拒绝审计事件。
|
||||
- [ ] browser smoke:AI 修改一篇本地 markdown 后,会话面板显示 changed files。
|
||||
|
||||
补充验证:
|
||||
|
||||
- [x] `cargo fmt --check --all`
|
||||
- [x] `cargo test -p mnote-web hermes_client_acp_run_registers_scoped_runtime_record_in_convex -- --nocapture`
|
||||
- [x] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture`
|
||||
- [x] `cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`
|
||||
|
||||
---
|
||||
|
||||
## 4. P3 本地搜索、反链和资源引用索引
|
||||
|
||||
对应 `01-05` 执行顺序:`4. 本地搜索、反链和资源引用索引`。
|
||||
|
||||
目标:本地 workspace 不依赖 Convex search 也能搜索正文、查反链、查资源引用和标签。
|
||||
|
||||
### 4.1 索引边界
|
||||
|
||||
- [ ] 设计本地索引目录:建议放在 workspace `.mnote/index/` 或 `/mnt/Data1T/Mnote_data/control-plane/index/`,二者职责需明确。
|
||||
- [ ] 确认索引只扫描授权 root 内文件,不扫描用户未授权目录。
|
||||
- [ ] 定义索引输入:markdown 正文、frontmatter、附件引用、mindmap / office resource metadata。
|
||||
- [ ] 定义索引输出:全文 search、backlinks、resource refs、tags、recent changes。
|
||||
|
||||
### 4.2 索引更新
|
||||
|
||||
- [ ] 本地文件 watcher 事件触发增量索引。
|
||||
- [ ] 手动 refresh / resync 触发 root 全量索引。
|
||||
- [ ] 文件移动、重命名、删除、恢复后同步更新索引。
|
||||
- [ ] AI 写入和 tiptap 保存后更新索引。
|
||||
- [ ] 索引损坏时可重建,不影响正文文件。
|
||||
|
||||
### 4.3 搜索体验
|
||||
|
||||
- [ ] 全局搜索优先搜索当前 workspace 本地索引。
|
||||
- [ ] 搜索结果显示文件路径、标题、命中片段和资源类型。
|
||||
- [ ] 反链面板读取本地索引,不再依赖云端搜索。
|
||||
- [ ] 标签列表读取本地索引。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [ ] 单测:markdown 链接和双链可生成 backlinks。
|
||||
- [ ] 单测:附件 / mindmap / office 引用可生成 resource refs。
|
||||
- [ ] browser smoke:新建页面后立即可搜索,重命名后搜索结果路径更新。
|
||||
|
||||
---
|
||||
|
||||
## 5. P4 分享与同步闭环
|
||||
|
||||
对应 `01-05` 执行顺序:`5. 分享与同步闭环`。
|
||||
|
||||
目标:个人本地空间默认隔离,显式分享后才产生共享 workspace / shared AI session,Convex 只作为权限、同步和协作控制面。
|
||||
|
||||
### 5.1 分享权限
|
||||
|
||||
- [ ] 定义 share grant 与 local access grant 的关系:分享不自动扩大本机文件系统权限。
|
||||
- [ ] share grant 支持 read / write / ai capability。
|
||||
- [ ] shared AI session 只能访问 share grant 允许的资源。
|
||||
- [ ] 管理员可查看和撤销 share grant。
|
||||
|
||||
### 5.2 同步缓存
|
||||
|
||||
- [ ] 设计 shared workspace cache 目录。
|
||||
- [ ] 云端同步到本地 cache 时保留来源、版本、权限和冲突信息。
|
||||
- [ ] 本地修改同步回云端前进行权限和版本校验。
|
||||
- [ ] 离线期间记录 pending changes,恢复在线后生成同步报告。
|
||||
|
||||
### 5.3 同步冲突报告
|
||||
|
||||
- [ ] 同步冲突复用 P1 的冲突 UI。
|
||||
- [ ] 冲突报告包含本地版本、远端版本、base version、修改 actor。
|
||||
- [ ] 管理员或 owner 可导出冲突报告。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [ ] 单测:share read 不允许写入。
|
||||
- [ ] 单测:shared AI session 不扩大 workspace root。
|
||||
- [ ] browser smoke:共享页面只读用户无法通过 AI 写入正文。
|
||||
|
||||
---
|
||||
|
||||
## 6. P5 插件资源模型产品化
|
||||
|
||||
对应 `01-05` 执行顺序:`6. 插件资源模型产品化`。
|
||||
|
||||
目标:simplemindmap / office 是 Resource Tree 对象;Markdown 正文只保留链接或嵌入引用,不把资源内容塞回 markdown 正文真相。
|
||||
|
||||
### 6.1 Resource Tree 对象统一
|
||||
|
||||
- [ ] 盘点 simplemindmap 当前 object identity、保存路径、打开路径。
|
||||
- [ ] 盘点 office 当前 object identity、保存路径、打开路径。
|
||||
- [ ] 统一资源创建、重命名、移动、删除、恢复命令到 `tree.resource.*`。
|
||||
- [ ] filetree 显示资源行,pagetree 只显示页面导航投影。
|
||||
- [ ] Markdown 中插入资源引用时,只写相对链接或嵌入引用。
|
||||
|
||||
### 6.2 AI 资源工具
|
||||
|
||||
- [ ] 设计 `mnote.mindmap.*` 工具:读取结构、增删改节点、移动节点、导出 markdown summary。
|
||||
- [ ] 设计 `mnote.office.*` 工具:读取文本摘要、写入建议、导出变更摘要;真实编辑优先复用 officecli / OnlyOffice 保存链。
|
||||
- [ ] AI 资源工具必须走授权 root 和 resource capability。
|
||||
- [ ] agent changed files 审计包含 mindmap / office 资源文件。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [ ] 单测:resource rename 不走 document command。
|
||||
- [ ] 单测:mindmap resource 删除 / 恢复不破坏 markdown 页面。
|
||||
- [ ] browser smoke:从 markdown 打开 mindmap / office,修改保存后 filetree 和引用状态同步。
|
||||
|
||||
---
|
||||
|
||||
## 7. P6 旧 Convex 数据迁移产品化
|
||||
|
||||
对应 `01-05` 执行顺序:`7. 旧 Convex 数据迁移产品化`。
|
||||
|
||||
目标:把旧 Convex workspace 迁移成本变成可视、可回滚、可验证的产品流程。
|
||||
|
||||
### 7.1 导出与备份
|
||||
|
||||
- [ ] 设计导出入口:选择 Convex workspace,选择目标本地 root。
|
||||
- [ ] 导出前创建备份目录和 manifest。
|
||||
- [ ] 导出页面为 `.md`,资源为 Resource Tree 文件,附件保持相对路径。
|
||||
- [ ] 导出过程记录进度:总页面数、已完成、失败、跳过、冲突。
|
||||
|
||||
### 7.2 冲突与回滚
|
||||
|
||||
- [ ] 目标 root 已有同名文件时生成冲突报告,不直接覆盖。
|
||||
- [ ] 支持 dry run:只生成迁移计划,不写文件。
|
||||
- [ ] 支持回滚:根据 manifest 删除本次新增文件或恢复备份文件。
|
||||
- [ ] 导出完成后自动跑本地索引重建。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [ ] 脚本 smoke:Convex fixture 导出到本地 root。
|
||||
- [ ] 脚本 smoke:同名文件冲突时不覆盖。
|
||||
- [ ] 脚本 smoke:回滚后 root 回到导出前状态。
|
||||
|
||||
---
|
||||
|
||||
## 8. P7 Page Aggregate / tree command / realtime 兼容链瘦身
|
||||
|
||||
对应 `01-05` 执行顺序:`8. Page Aggregate / tree command / realtime 兼容链继续瘦身`。
|
||||
|
||||
目标:不是继续扩新功能,而是减少双真相、双命令面、补偿链和旧 Convex runtime fallback。
|
||||
|
||||
### 8.1 Page Aggregate 单一真源
|
||||
|
||||
- [ ] 盘点 `documents.content` 仍作为正文兼容源的入口。
|
||||
- [ ] 让本地 `.md` 与 EditorBlockDocument projection 的读写边界写入 5-5 / 5-6。
|
||||
- [ ] 标题、正文、页面设置写入后只通过 page aggregate 刷新 UI。
|
||||
- [ ] 删除或降级前端手工拼 `meta + content` 的 runtime fallback。
|
||||
- [ ] AI 页面设置写入走统一 page aggregate / page command 入口。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [ ] `cargo test -p mnote-web page_aggregate -- --nocapture`
|
||||
- [ ] browser smoke:标题、正文、页面设置保存后刷新仍一致。
|
||||
|
||||
### 8.2 Tree command cutover
|
||||
|
||||
- [ ] 盘点仍在 runtime route / adapter / bridge / CLI 中使用的 `documents.*` 命令。
|
||||
- [ ] 新增命令统一命名为 `tree.*` 或 `tree.resource.*`。
|
||||
- [ ] 对历史 `documents.*` 命令只保留 compat adapter,不再扩展新语义。
|
||||
- [ ] 资源 rename、移动、删除、恢复补齐 `tree.resource.*`。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [ ] `cargo test -p mnote-web tree_command -- --nocapture`
|
||||
- [ ] browser smoke:filetree 新建、重命名、移动、删除、恢复不触发页面 reload。
|
||||
|
||||
### 8.3 Tree realtime live cache
|
||||
|
||||
- [ ] 盘点 Sidebar、page subtree、filetree、preferred snapshot 的数据来源。
|
||||
- [ ] 统一 snapshot / delta / resync consumer 到同一 live cache。
|
||||
- [ ] SSE fallback 只作为 WS 不可用时的降级,不作为并行主链。
|
||||
- [ ] 双浏览器文件树操作无刷新同步。
|
||||
- [ ] 删除不必要的 polling / refetch 补偿链。
|
||||
|
||||
建议验证:
|
||||
|
||||
- [ ] `cargo test -p mnote-web local_folder -- --nocapture`
|
||||
- [ ] 双浏览器 smoke:页面、文件、垃圾箱操作互相同步且不刷新页面。
|
||||
|
||||
---
|
||||
|
||||
## 9. 每轮推进固定验证包
|
||||
|
||||
后续每次持续推进本 checklist,至少执行与改动相关的子集;跨域改动需要执行完整包。
|
||||
|
||||
- [ ] `cargo fmt --check --all`
|
||||
- [ ] `cargo test -p mnote-web local_folder -- --nocapture`
|
||||
- [ ] `cargo test -p mnote-web local_access_policy -- --nocapture`
|
||||
- [ ] `cargo test -p mnote-web hermes_client_local_acp -- --nocapture`
|
||||
- [ ] `cargo test -p mnote-web tree_command -- --nocapture`
|
||||
- [ ] `cargo test -p mnote-web page_aggregate -- --nocapture`
|
||||
- [ ] `npm run check:local-first-convex-guard`
|
||||
- [ ] `git diff --check -- <changed-files>`
|
||||
- [ ] 影响 UI / 交互时补 browser smoke,并把脚本名写回对应阶段。
|
||||
|
||||
---
|
||||
|
||||
## 10. done 迁移标准
|
||||
|
||||
本文件迁入 `done/` 前必须满足:
|
||||
|
||||
- [ ] P0 管理员目录授权 UI / API 完成并有 browser smoke。
|
||||
- [ ] P1 冲突处理 UI 完成,并覆盖 tiptap 保存与 agent 写回冲突。
|
||||
- [ ] P2 agent changed files / diff 审计完成,并能在 AI 会话 UI 查看。
|
||||
- [ ] P3 本地搜索 / 反链 / 资源引用索引完成最小闭环。
|
||||
- [ ] P4 分享与同步闭环完成 read / write / ai capability 最小闭环。
|
||||
- [ ] P5 simplemindmap / office 资源模型完成 Resource Tree 产品化。
|
||||
- [ ] P6 Convex 导出到本地 workspace 有 dry run、备份、冲突报告和回滚。
|
||||
- [ ] P7 Page Aggregate / tree command / realtime 兼容链完成阶段性瘦身,并把被替代 process 稿移入 `old/` 或 `done/`。
|
||||
- [ ] `01-05-current-priority-overview.md` 同步更新状态,不再把已完成项描述为当前第一优先级。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::kernel::WorkspaceSourceKind;
|
||||
use serde::de;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -66,3 +68,193 @@ pub enum AiStructuredWriteKind {
|
||||
ReferenceEdge,
|
||||
PageBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiAccessPermissionLevel {
|
||||
Admin,
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
SharedRead,
|
||||
SharedWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiShareContext {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub share_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiAccessScope {
|
||||
pub user_id: String,
|
||||
pub workspace_id: String,
|
||||
pub session_id: String,
|
||||
pub source_kind: WorkspaceSourceKind,
|
||||
pub permission_level: AiAccessPermissionLevel,
|
||||
#[serde(default)]
|
||||
pub allowed_roots: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_file_paths: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_resource_ids: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub share_context: Option<AiShareContext>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AiAccessScopeRaw {
|
||||
user_id: String,
|
||||
workspace_id: String,
|
||||
session_id: String,
|
||||
source_kind: WorkspaceSourceKind,
|
||||
permission_level: AiAccessPermissionLevel,
|
||||
#[serde(default)]
|
||||
allowed_roots: Vec<String>,
|
||||
#[serde(default)]
|
||||
allowed_file_paths: Vec<String>,
|
||||
#[serde(default)]
|
||||
allowed_resource_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
share_context: Option<AiShareContext>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AiAccessScope {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = AiAccessScopeRaw::deserialize(deserializer)?;
|
||||
let scope = Self {
|
||||
user_id: raw.user_id,
|
||||
workspace_id: raw.workspace_id,
|
||||
session_id: raw.session_id,
|
||||
source_kind: raw.source_kind,
|
||||
permission_level: raw.permission_level,
|
||||
allowed_roots: raw.allowed_roots,
|
||||
allowed_file_paths: raw.allowed_file_paths,
|
||||
allowed_resource_ids: raw.allowed_resource_ids,
|
||||
share_context: raw.share_context,
|
||||
};
|
||||
scope.validate().map_err(de::Error::custom)?;
|
||||
Ok(scope)
|
||||
}
|
||||
}
|
||||
|
||||
impl AiAccessScope {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
reject_blank("userId", &self.user_id)?;
|
||||
reject_blank("workspaceId", &self.workspace_id)?;
|
||||
reject_blank("sessionId", &self.session_id)?;
|
||||
reject_blank_vec("allowedRoots", &self.allowed_roots)?;
|
||||
reject_blank_vec("allowedFilePaths", &self.allowed_file_paths)?;
|
||||
reject_blank_vec("allowedResourceIds", &self.allowed_resource_ids)?;
|
||||
if let Some(share_context) = &self.share_context {
|
||||
if let Some(share_id) = &share_context.share_id {
|
||||
reject_blank("shareContext.shareId", share_id)?;
|
||||
}
|
||||
if let Some(mode) = &share_context.mode {
|
||||
reject_blank("shareContext.mode", mode)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_blank(field: &str, value: &str) -> Result<(), String> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(format!("{field} must not be blank"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_blank_vec(field: &str, values: &[String]) -> Result<(), String> {
|
||||
for value in values {
|
||||
reject_blank(field, value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::WorkspaceSourceKind;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn ai_access_scope_uses_camel_case_payload() {
|
||||
let scope: AiAccessScope = serde_json::from_value(json!({
|
||||
"userId": "user_1",
|
||||
"workspaceId": "workspace_1",
|
||||
"sessionId": "session_1",
|
||||
"sourceKind": "local_folder",
|
||||
"permissionLevel": "read_write",
|
||||
"allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"],
|
||||
"allowedFilePaths": ["file:///mnt/Data1T/Mnote_data/users/user_1/我的空间/README.md"],
|
||||
"allowedResourceIds": ["local-md:README.md"],
|
||||
"shareContext": {
|
||||
"shareId": "share_1",
|
||||
"mode": "shared_write"
|
||||
}
|
||||
}))
|
||||
.expect("scope");
|
||||
|
||||
assert_eq!(scope.user_id, "user_1");
|
||||
assert_eq!(scope.workspace_id, "workspace_1");
|
||||
assert_eq!(scope.session_id, "session_1");
|
||||
assert_eq!(scope.source_kind, WorkspaceSourceKind::LocalFolder);
|
||||
assert_eq!(scope.permission_level, AiAccessPermissionLevel::ReadWrite);
|
||||
assert_eq!(scope.allowed_roots.len(), 1);
|
||||
assert_eq!(scope.allowed_file_paths.len(), 1);
|
||||
assert_eq!(scope.allowed_resource_ids, vec!["local-md:README.md"]);
|
||||
assert_eq!(
|
||||
scope
|
||||
.share_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.share_id.as_deref()),
|
||||
Some("share_1")
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(&scope).expect("serialized");
|
||||
assert_eq!(serialized["userId"], "user_1");
|
||||
assert_eq!(serialized["permissionLevel"], "read_write");
|
||||
assert_eq!(
|
||||
serialized["allowedRoots"][0],
|
||||
"file:///mnt/Data1T/Mnote_data/users/user_1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_access_scope_rejects_blank_required_fields_and_paths() {
|
||||
let error = serde_json::from_value::<AiAccessScope>(json!({
|
||||
"userId": " ",
|
||||
"workspaceId": "workspace_1",
|
||||
"sessionId": "session_1",
|
||||
"sourceKind": "local_folder",
|
||||
"permissionLevel": "read_only",
|
||||
"allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"],
|
||||
"allowedFilePaths": [],
|
||||
"allowedResourceIds": []
|
||||
}))
|
||||
.expect_err("blank user id should be rejected");
|
||||
assert!(error.to_string().contains("userId"));
|
||||
|
||||
let error = serde_json::from_value::<AiAccessScope>(json!({
|
||||
"userId": "user_1",
|
||||
"workspaceId": "workspace_1",
|
||||
"sessionId": "session_1",
|
||||
"sourceKind": "local_folder",
|
||||
"permissionLevel": "read_only",
|
||||
"allowedRoots": [" "],
|
||||
"allowedFilePaths": [],
|
||||
"allowedResourceIds": []
|
||||
}))
|
||||
.expect_err("blank allowed root should be rejected");
|
||||
assert!(error.to_string().contains("allowedRoots"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,33 @@ pub struct WorkspaceSource {
|
||||
pub capabilities: Vec<WorkspaceSourceCapability>,
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum KernelProjectionCapability {
|
||||
@@ -528,6 +555,14 @@ pub struct DocumentReadPageSubtree {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentContentResult {
|
||||
pub content: Value,
|
||||
#[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>,
|
||||
pub revision: u64,
|
||||
pub conflict_detection_key: String,
|
||||
pub title: Option<String>,
|
||||
@@ -730,4 +765,56 @@ mod tests {
|
||||
serde_json::from_value(value).expect("workspace source 应可反序列化");
|
||||
assert_eq!(decoded, source);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ pub mod search;
|
||||
pub mod tool;
|
||||
|
||||
pub use ai::{
|
||||
AiEvent, AiEventKind, AiRuntimeOwner, AiSession, AiStructuredWriteKind,
|
||||
AiStructuredWriteResult, AiToolCall,
|
||||
AiAccessPermissionLevel, AiAccessScope, AiEvent, AiEventKind, AiRuntimeOwner, AiSession,
|
||||
AiShareContext, AiStructuredWriteKind, AiStructuredWriteResult, AiToolCall,
|
||||
};
|
||||
pub use command::{
|
||||
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
|
||||
@@ -48,8 +48,8 @@ pub use kernel::{
|
||||
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest,
|
||||
KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult,
|
||||
KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult,
|
||||
KernelTraverseGraph, KernelUpdateNode, WorkspaceSource, WorkspaceSourceCapability,
|
||||
WorkspaceSourceKind,
|
||||
KernelTraverseGraph, KernelUpdateNode, PageBodyWriteRequest, WorkspaceSource,
|
||||
WorkspaceSourceCapability, WorkspaceSourceKind,
|
||||
};
|
||||
pub use mindmap::{
|
||||
MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities,
|
||||
@@ -289,6 +289,7 @@ mod tests {
|
||||
content: serde_json::json!([]),
|
||||
revision: serde_json::json!(7),
|
||||
conflict_detection_key: serde_json::json!("page_1:7"),
|
||||
file_version: serde_json::json!(null),
|
||||
block_document: serde_json::json!({
|
||||
"documentId": "page_1",
|
||||
"rootBlockIds": [],
|
||||
|
||||
@@ -139,6 +139,7 @@ mod tests {
|
||||
assert_eq!(body.block_projection_version, 0);
|
||||
assert_eq!(body.block_document, json!(null));
|
||||
assert_eq!(body.projection_source, "");
|
||||
assert_eq!(body.file_version, json!(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +150,8 @@ pub struct PageBody {
|
||||
pub revision: Value,
|
||||
pub conflict_detection_key: Value,
|
||||
#[serde(default)]
|
||||
pub file_version: Value,
|
||||
#[serde(default)]
|
||||
pub block_document: Value,
|
||||
#[serde(default)]
|
||||
pub block_projection_version: u32,
|
||||
|
||||
@@ -136,6 +136,13 @@ impl AcpClient {
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
if let Some(env) = env_overrides {
|
||||
if let Some(workspace_root) = env.get("MNOTE_AI_WORKSPACE_ROOT") {
|
||||
let workspace_root = std::path::Path::new(workspace_root);
|
||||
if workspace_root.is_dir() {
|
||||
// 本地 workspace run 以授权根目录作为进程工作目录,贴近 VSCode agent 行为。
|
||||
command.current_dir(workspace_root);
|
||||
}
|
||||
}
|
||||
command.envs(env);
|
||||
}
|
||||
let mut child = command.spawn().map_err(AcpError::Spawn)?;
|
||||
|
||||
@@ -116,7 +116,7 @@ impl AcpRuntimeManager {
|
||||
/// Create a new runtime manager with built-in default configurations.
|
||||
///
|
||||
/// Reads environment variables to configure Hermes and Reasonix runtimes.
|
||||
/// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "hermes").
|
||||
/// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "reasonix").
|
||||
pub fn from_env() -> Self {
|
||||
let mut runtimes: HashMap<String, AcpRuntimeConfig> = HashMap::new();
|
||||
|
||||
@@ -153,7 +153,8 @@ impl AcpRuntimeManager {
|
||||
);
|
||||
}
|
||||
|
||||
let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "hermes".into());
|
||||
let default =
|
||||
env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "reasonix".into());
|
||||
|
||||
Self {
|
||||
runtimes,
|
||||
|
||||
@@ -17,6 +17,8 @@ const HEADER_SESSION_ID: &str = "x-mnote-session-id";
|
||||
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
|
||||
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
|
||||
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
|
||||
const COOKIE_ACTOR_ID: &str = "mnote_actor_id";
|
||||
const COOKIE_ACTOR_TYPE: &str = "mnote_actor_type";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -79,8 +81,10 @@ impl RequestContext {
|
||||
authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()),
|
||||
cookie_header: header_value(headers, axum::http::header::COOKIE.as_str()),
|
||||
actor_id: header_value(headers, HEADER_ACTOR_ID)
|
||||
.or_else(|| cookie_value(headers, COOKIE_ACTOR_ID))
|
||||
.unwrap_or_else(|| "anonymous".into()),
|
||||
actor_type: header_value(headers, HEADER_ACTOR_TYPE)
|
||||
.or_else(|| cookie_value(headers, COOKIE_ACTOR_TYPE))
|
||||
.unwrap_or_else(|| "anonymous".into()),
|
||||
session_id: header_value(headers, HEADER_SESSION_ID),
|
||||
},
|
||||
@@ -106,6 +110,10 @@ impl RequestContext {
|
||||
if let Some(workspace_id) = &self.workspace.workspace_id {
|
||||
insert_header(headers, HEADER_WORKSPACE_ID, workspace_id);
|
||||
}
|
||||
if self.auth.actor_id.trim() != "anonymous" && !self.auth.actor_id.trim().is_empty() {
|
||||
append_cookie(headers, COOKIE_ACTOR_ID, self.auth.actor_id.trim());
|
||||
append_cookie(headers, COOKIE_ACTOR_TYPE, self.auth.actor_type.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +149,14 @@ fn insert_header(headers: &mut HeaderMap, key: &str, value: &str) {
|
||||
headers.insert(name, value);
|
||||
}
|
||||
|
||||
fn append_cookie(headers: &mut HeaderMap, name: &str, value: &str) {
|
||||
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
|
||||
let Ok(header_value) = HeaderValue::from_str(&cookie) else {
|
||||
return;
|
||||
};
|
||||
headers.append(axum::http::header::SET_COOKIE, header_value);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -164,4 +180,40 @@ mod tests {
|
||||
assert_eq!(context.workspace.workspace_id.as_deref(), Some("ws_demo"));
|
||||
assert_eq!(context.auth.actor_id, "user_demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_context_falls_back_to_actor_cookies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::COOKIE,
|
||||
HeaderValue::from_static("mnote_actor_id=user_cookie; mnote_actor_type=user"),
|
||||
);
|
||||
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/".parse::<Uri>().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
assert_eq!(context.auth.actor_id, "user_cookie");
|
||||
assert_eq!(context.auth.actor_type, "user");
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let cookie_header = headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|value| value.to_str().ok())?;
|
||||
for part in cookie_header.split(';') {
|
||||
let Some((cookie_name, cookie_value)) = part.trim().split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if cookie_name.trim() == name {
|
||||
let value = cookie_value.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::command_support::execute_runtime_command_via_convex_with_artifacts;
|
||||
use crate::routes::ensure_local_workspace_access;
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn create_summary(
|
||||
state: &AppState,
|
||||
@@ -71,6 +74,85 @@ async fn create_artifact_node(
|
||||
format!("ai_note_{}_{}", document_id, context.trace.request_id)
|
||||
};
|
||||
|
||||
if input.effective_source_kind().as_deref() == Some("local_folder") {
|
||||
let root_uri = input.effective_root_uri().ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(context)
|
||||
})?;
|
||||
ensure_local_workspace_access(context, &root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(json!({
|
||||
"dryRun": true,
|
||||
"commandName": "tree.node.create",
|
||||
"commandId": command_id,
|
||||
"artifactType": node_type,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"diff": [{"op": "create_artifact", "artifactType": node_type}]
|
||||
}));
|
||||
}
|
||||
let root_path = parse_local_root_path(&root_uri)?;
|
||||
let artifact_dir = root_path.join(".mnote").join("artifacts");
|
||||
fs::create_dir_all(&artifact_dir).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_artifact_write_failed",
|
||||
format!(
|
||||
"无法创建本地 artifact 目录 {}: {error}",
|
||||
artifact_dir.display()
|
||||
),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let artifact_path = artifact_dir.join(format!(
|
||||
"{}.json",
|
||||
sanitize_local_artifact_file_name(&artifact_document_id)
|
||||
));
|
||||
let artifact_value = json!({
|
||||
"schema": "mnote.local_artifact.v1",
|
||||
"artifactType": node_type,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"content": content,
|
||||
"createdAt": context.trace.trace_id,
|
||||
});
|
||||
fs::write(
|
||||
&artifact_path,
|
||||
serde_json::to_string_pretty(&artifact_value).map_err(|error| {
|
||||
WebError::internal(format!("本地 artifact 序列化失败: {error}"))
|
||||
.with_context(context)
|
||||
})?,
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_artifact_write_failed",
|
||||
format!(
|
||||
"无法写入本地 artifact 文件 {}: {error}",
|
||||
artifact_path.display()
|
||||
),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
return Ok(json!({
|
||||
"dryRun": false,
|
||||
"commandName": "tree.node.create",
|
||||
"commandId": command_id,
|
||||
"source": "local_folder",
|
||||
"artifactType": node_type,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"result": {
|
||||
"ok": true,
|
||||
"source": "local_folder",
|
||||
"artifactPath": artifact_path,
|
||||
"artifactDocumentId": artifact_document_id,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if input.dry_run.unwrap_or(false) {
|
||||
return Ok(json!({
|
||||
"dryRun": true,
|
||||
@@ -166,6 +248,33 @@ async fn create_artifact_node(
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_local_root_path(root_uri: &str) -> Result<PathBuf, WebError> {
|
||||
let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") {
|
||||
stripped.trim()
|
||||
} else {
|
||||
root_uri.trim()
|
||||
};
|
||||
if root_path.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_root_required",
|
||||
"缺少本地文件夹 rootUri",
|
||||
));
|
||||
}
|
||||
Ok(PathBuf::from(root_path))
|
||||
}
|
||||
|
||||
fn sanitize_local_artifact_file_name(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
_ => ch,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
|
||||
if !input.has_idempotency_key() {
|
||||
return Err(WebError::bad_request_code(
|
||||
|
||||
@@ -707,6 +707,14 @@ pub(crate) fn ensure_write_contract(
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1490,6 +1498,44 @@ fn build_insert_block(block_id: &str, value: &Value) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ensure_write_contract_rejects_read_only_ai_scope() {
|
||||
let context = RequestContext::from_http_parts(
|
||||
&axum::http::Method::POST,
|
||||
&"/api/hermes/tools".parse().expect("uri"),
|
||||
&axum::http::HeaderMap::new(),
|
||||
);
|
||||
let input = ToolCallInput {
|
||||
tool_name: "mnote.block.replace".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
document_id: Some("doc_1".into()),
|
||||
source_kind: Some("local_folder".into()),
|
||||
root_uri: Some("file:///tmp/mnote-readonly".into()),
|
||||
actor_id: Some("user_1".into()),
|
||||
profile: None,
|
||||
session_id: Some("sess_1".into()),
|
||||
run_id: Some("run_1".into()),
|
||||
tool_call_id: Some("tool_1".into()),
|
||||
trace_id: Some("trace_1".into()),
|
||||
idempotency_key: Some("idem_1".into()),
|
||||
dry_run: Some(false),
|
||||
capability_scope: None,
|
||||
args: Some(json!({
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "read_only",
|
||||
"allowedRoots": ["file:///tmp/mnote-readonly"]
|
||||
}
|
||||
})),
|
||||
};
|
||||
|
||||
let error = ensure_write_contract(&context, &input).expect_err("read only rejected");
|
||||
assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
error.message(),
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_to_text_reads_projection_content_nodes() {
|
||||
let value = json!([
|
||||
|
||||
@@ -3,9 +3,38 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn file_version_from_aggregate(aggregate: &Value) -> Value {
|
||||
[
|
||||
"/body/fileVersion",
|
||||
"/body/file_version",
|
||||
"/body/conflictDetectionKey",
|
||||
"/body/conflict_detection_key",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|pointer| {
|
||||
aggregate
|
||||
.pointer(pointer)
|
||||
.filter(|value| !value.is_null())
|
||||
.cloned()
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
fn conflict_detection_key_from_aggregate(aggregate: &Value) -> Option<&str> {
|
||||
[
|
||||
"/body/conflictDetectionKey",
|
||||
"/body/conflict_detection_key",
|
||||
"/body/fileVersion",
|
||||
"/body/file_version",
|
||||
]
|
||||
.iter()
|
||||
.find_map(|pointer| aggregate.pointer(pointer).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
pub async fn doc_fetch(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
@@ -13,16 +42,28 @@ pub async fn doc_fetch(
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id = input.effective_document_id().unwrap_or_default();
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
|
||||
// 本地文件路径检测:直接读取 .md 文件,不经过 Convex
|
||||
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
|
||||
// 本地文件路径检测:直接读取授权 root 内的 .md 文件,不经过 Convex。
|
||||
let is_local_file = document_id.starts_with('/')
|
||||
|| document_id.starts_with("./")
|
||||
|| document_id.starts_with("file://");
|
||||
if is_local_file {
|
||||
use std::fs;
|
||||
let path = &document_id;
|
||||
let root_uri = local_root_uri_for_tool(input).ok_or_else(|| {
|
||||
WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"ai_scope_root_uri_required",
|
||||
"本地文件读取需要授权 rootUri",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let path = crate::routes::ensure_local_path_read_access(context, &root_uri, &document_id)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let content = fs::read_to_string(path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("无法读取本地文件 {path}: {error}"),
|
||||
format!("无法读取本地文件: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
@@ -42,6 +83,7 @@ pub async fn doc_fetch(
|
||||
"source": "local_fs",
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"rootUri": root_uri,
|
||||
"format": "markdown",
|
||||
"detail": "simple",
|
||||
"scope": "full",
|
||||
@@ -221,6 +263,7 @@ pub async fn doc_fetch(
|
||||
"source": source,
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"fileVersion": file_version_from_aggregate(&aggregate),
|
||||
"format": format,
|
||||
"detail": detail,
|
||||
"scope": scope,
|
||||
@@ -238,6 +281,9 @@ pub async fn doc_find(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
if let Some(document_id) = input.effective_document_id() {
|
||||
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
}
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let query = input.arg_string("query").ok_or_else(|| {
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.doc.find 缺少 query")
|
||||
@@ -287,6 +333,7 @@ pub async fn doc_find(
|
||||
"workspaceId": input.effective_workspace_id(),
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"fileVersion": file_version_from_aggregate(&aggregate),
|
||||
"matches": matches
|
||||
}))
|
||||
}
|
||||
@@ -404,6 +451,7 @@ pub async fn plan_update(
|
||||
"workspaceId": input.effective_workspace_id(),
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": aggregate.pointer("/body/conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||
"fileVersion": file_version_from_aggregate(&aggregate),
|
||||
"command": command,
|
||||
"diff": diff,
|
||||
"warnings": if plan_blocked {
|
||||
@@ -452,19 +500,81 @@ pub(crate) async fn aggregate_value(
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "页面工具缺少 documentId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let source_kind = input.effective_source_kind();
|
||||
let root_uri = input.effective_root_uri();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
state,
|
||||
context,
|
||||
&document_id,
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
source_kind.as_deref(),
|
||||
root_uri.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_ai_scope_resource_allowed(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
document_id: &str,
|
||||
) -> 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())
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if allowed.is_empty() || allowed.contains(document_id) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_read_forbidden",
|
||||
"当前 AI scope 不允许读取该资源",
|
||||
)
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
fn local_root_uri_for_tool(input: &ToolCallInput) -> Option<String> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn block_projection_blocks(aggregate: &Value) -> Vec<Value> {
|
||||
aggregate
|
||||
.pointer("/body/blockDocument/blocks")
|
||||
@@ -1377,7 +1487,11 @@ pub async fn doc_markdown_edit(
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id = input.effective_document_id().unwrap_or_default();
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let source_kind = input.effective_source_kind();
|
||||
let root_uri = input.effective_root_uri();
|
||||
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
|
||||
let is_local_workspace =
|
||||
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
|
||||
crate::hermes_tools::block::ensure_write_contract(context, input)?;
|
||||
|
||||
// 1. 读取当前文档内容(markdown 形式)
|
||||
@@ -1400,7 +1514,14 @@ pub async fn doc_markdown_edit(
|
||||
} else {
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
(blocks_to_markdown(&blocks, true), "convex")
|
||||
(
|
||||
blocks_to_markdown(&blocks, true),
|
||||
if is_local_workspace {
|
||||
"local_folder"
|
||||
} else {
|
||||
"convex"
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
// 2. 解析 operations
|
||||
@@ -1483,6 +1604,14 @@ pub async fn doc_markdown_edit(
|
||||
}
|
||||
}
|
||||
|
||||
if applied == 0 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_markdown_edit_no_operations_applied",
|
||||
"markdown_edit 没有任何 search/replace 操作命中,未执行写入",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
|
||||
// 4. 构建 changedText 摘要
|
||||
let changed_text = if applied > 0 {
|
||||
operations
|
||||
@@ -1517,17 +1646,19 @@ pub async fn doc_markdown_edit(
|
||||
} else {
|
||||
// 7-27: 在线写回以最终 markdown 为真源,直接生成 block content
|
||||
// 与 /api/documents/save 共用同一个 RuntimeCommandEnvelopeWire 路径
|
||||
let (blocks, original_content) = match aggregate_value(state, context, input).await {
|
||||
Ok(ref agg) => (
|
||||
block_projection_blocks(agg),
|
||||
crate::hermes_tools::block::current_body_content(agg),
|
||||
),
|
||||
Err(_) if use_full_content.is_some() => {
|
||||
// 空文档 + full_content:跳过读取
|
||||
(vec![], json!([]))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let (aggregate, blocks, original_content) =
|
||||
match aggregate_value(state, context, input).await {
|
||||
Ok(agg) => {
|
||||
let blocks = block_projection_blocks(&agg);
|
||||
let original_content = crate::hermes_tools::block::current_body_content(&agg);
|
||||
(agg, blocks, original_content)
|
||||
}
|
||||
Err(_) if use_full_content.is_some() => {
|
||||
// 空文档 + full_content:跳过读取
|
||||
(Value::Null, vec![], json!([]))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let parsed = parse_final_markdown_to_blocks(&md, &blocks);
|
||||
let next_content = build_page_content(&original_content, &parsed);
|
||||
@@ -1549,11 +1680,62 @@ pub async fn doc_markdown_edit(
|
||||
} else {
|
||||
// 直接构造 RuntimeCommandEnvelopeWire(与 /api/documents/save 相同)
|
||||
let command_id = format!("markdown_edit_{}", context.trace.request_id);
|
||||
let file_version = file_version_from_aggregate(&aggregate);
|
||||
let conflict_detection_key = conflict_detection_key_from_aggregate(&aggregate);
|
||||
if is_local_workspace {
|
||||
let root_uri = root_uri.as_deref().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_root_required",
|
||||
"缺少本地文件夹 rootUri",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::ensure_local_workspace_access(context, root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let expected_file_version = file_version
|
||||
.as_str()
|
||||
.or(conflict_detection_key)
|
||||
.map(|value| value.to_string());
|
||||
let result = crate::routes::write_local_markdown_page_body(
|
||||
&core_protocol::PageBodyWriteRequest {
|
||||
document_id: document_id.clone(),
|
||||
workspace_id: workspace_id.clone().unwrap_or_default(),
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version,
|
||||
base_content_hash: None,
|
||||
content_format: "editorBlocks".into(),
|
||||
content: next_content,
|
||||
editor_source: Some("mnote.doc.markdown_edit".into()),
|
||||
},
|
||||
)?;
|
||||
return Ok(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.doc.markdown_edit.v1",
|
||||
"source": "local_folder",
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"operationsApplied": applied,
|
||||
"operationsFailed": failed.len(),
|
||||
"failedOperations": failed,
|
||||
"changedText": changed_text,
|
||||
"fileVersion": file_version,
|
||||
"applyResult": {
|
||||
"commandName": "page.body.write",
|
||||
"commandId": command_id,
|
||||
"changedBlocks": changed_blocks,
|
||||
"result": result
|
||||
}
|
||||
}));
|
||||
}
|
||||
let payload = json!({
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"content": next_content,
|
||||
"mode": "replace",
|
||||
"revision": aggregate.pointer("/body/revision").cloned().unwrap_or(Value::Null),
|
||||
"conflictDetectionKey": conflict_detection_key.map(Value::from).unwrap_or(Value::Null),
|
||||
"fileVersion": file_version,
|
||||
});
|
||||
use bridge_runtime::{
|
||||
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||
@@ -1573,8 +1755,8 @@ pub async fn doc_markdown_edit(
|
||||
source: RuntimeSourceWire {
|
||||
channel: "mnote-hermes".into(),
|
||||
client: "mnote-hermes-plugin".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
source_kind,
|
||||
root_uri,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
|
||||
@@ -173,7 +173,7 @@ fn doc_plan_update_tool() -> Value {
|
||||
fn block_replace_tool() -> Value {
|
||||
write_tool(
|
||||
"mnote.block.replace",
|
||||
"替换指定块内容;真实写入走 Rust page.body.save 链路",
|
||||
"兼容块写工具:替换指定块内容;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,必要时再走 Rust page.body.write 兼容链路",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"blockId": { "type": "string" },
|
||||
@@ -394,7 +394,7 @@ fn page_get_tool() -> Value {
|
||||
fn page_save_tool() -> Value {
|
||||
json!({
|
||||
"name": "mnote.page.save",
|
||||
"description": "保存当前页面正文;replace 覆盖正文,append/prepend 会先读取当前 Page Aggregate 后合成完整正文再保存",
|
||||
"description": "粗粒度兼容兜底:保存当前页面正文;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,只有整页覆盖/追加且其它工具无法表达时使用",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["page.write"],
|
||||
"status": "available",
|
||||
@@ -445,7 +445,7 @@ fn available_tool(
|
||||
fn doc_markdown_edit_tool() -> Value {
|
||||
let mut tool = write_tool(
|
||||
"mnote.doc.markdown_edit",
|
||||
"通过文本级搜索替换编辑 markdown 内容(AI 编辑主路径)。在线 Convex 文档和本地 .md 文件共用,不需要 blockId。",
|
||||
"兼容 / 远端代理 fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 默认优先让 agent 原生 patch/diff 直接编辑授权文件;仅在需要 MNote 兼容工具、远端代理或结构校验时使用。",
|
||||
["block.write", "page.write"],
|
||||
json!({
|
||||
"operations": {
|
||||
@@ -489,6 +489,8 @@ fn tool_annotations(
|
||||
"readonly": readonly,
|
||||
"destructive": destructive,
|
||||
"idempotent": idempotent,
|
||||
"readOnly": readonly,
|
||||
"requiresWritePermission": !readonly,
|
||||
"requiresApproval": requires_approval,
|
||||
"approvalMode": if requires_approval { "review" } else { "yolo" },
|
||||
"runtimeOwner": "mnote-web",
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ToolCallInput {
|
||||
pub tool_name: String,
|
||||
pub workspace_id: Option<String>,
|
||||
pub document_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
@@ -58,6 +60,24 @@ impl ToolCallInput {
|
||||
.or_else(|| self.arg_string("documentId"))
|
||||
}
|
||||
|
||||
pub fn effective_source_kind(&self) -> Option<String> {
|
||||
self.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.arg_string("sourceKind"))
|
||||
}
|
||||
|
||||
pub fn effective_root_uri(&self) -> Option<String> {
|
||||
self.root_uri
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| self.arg_string("rootUri"))
|
||||
}
|
||||
|
||||
pub fn effective_tool_call_id(&self) -> String {
|
||||
self.tool_call_id
|
||||
.as_deref()
|
||||
@@ -91,4 +111,37 @@ impl ToolCallInput {
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn ai_access_scope(&self) -> Option<&Value> {
|
||||
self.args.as_ref().and_then(|args| {
|
||||
args.get("aiAccessScope")
|
||||
.or_else(|| args.get("ai_access_scope"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ai_access_permission_level(&self) -> Option<String> {
|
||||
self.ai_access_scope()
|
||||
.and_then(|scope| {
|
||||
scope
|
||||
.get("permissionLevel")
|
||||
.or_else(|| scope.get("permission_level"))
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn ai_access_scope_is_read_only(&self) -> bool {
|
||||
self.ai_access_permission_level()
|
||||
.map(|level| {
|
||||
let normalized = level.trim().to_ascii_lowercase();
|
||||
normalized == "read"
|
||||
|| normalized == "readonly"
|
||||
|| normalized == "read_only"
|
||||
|| normalized == "shared_read"
|
||||
|| (normalized.contains("read") && !normalized.contains("write"))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,17 @@ pub async fn page_get(
|
||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::hermes_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let source_kind = input.effective_source_kind();
|
||||
let root_uri = input.effective_root_uri();
|
||||
let aggregate = build_page_aggregate_snapshot(
|
||||
state,
|
||||
context,
|
||||
&document_id,
|
||||
workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
source_kind.as_deref(),
|
||||
root_uri.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let aggregate_value =
|
||||
@@ -83,6 +86,14 @@ fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Res
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
if input.ai_access_scope_is_read_only() {
|
||||
return Err(WebError::new(
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"mnote_tool_ai_scope_write_forbidden",
|
||||
"当前 AI scope 是只读权限,禁止执行写入型 mnote tool",
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -200,6 +211,74 @@ async fn page_command(
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if input.effective_source_kind().as_deref() == Some("local_folder") {
|
||||
let root_uri = input.effective_root_uri().ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::ensure_local_workspace_access(context, &root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
let local_result = match command_name {
|
||||
"page.body.save" => {
|
||||
let content = payload.get("content").cloned().unwrap_or(Value::Null);
|
||||
crate::routes::write_local_markdown_page_body(
|
||||
&core_protocol::PageBodyWriteRequest {
|
||||
document_id: document_id.clone(),
|
||||
workspace_id: workspace_id.clone().unwrap_or_default(),
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.clone(),
|
||||
expected_file_version: input.arg_string("expectedFileVersion"),
|
||||
base_content_hash: input.arg_string("baseContentHash"),
|
||||
content_format: "editorBlocks".into(),
|
||||
content,
|
||||
editor_source: Some("mnote.page.save".into()),
|
||||
},
|
||||
)?
|
||||
}
|
||||
"page.head.updateTitle" => {
|
||||
let title = payload
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.page.update_title 缺少 title",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::update_local_markdown_title(&root_uri, &document_id, title)?
|
||||
}
|
||||
"page.layout.updateOptions" => {
|
||||
let options = payload.get("options").cloned().ok_or_else(|| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
"mnote.page.update_options 缺少 options",
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
crate::routes::update_local_page_options(&root_uri, &document_id, &options)?
|
||||
}
|
||||
_ => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("local source 暂不支持页面命令 {command_name}"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
};
|
||||
let mut result = json!({
|
||||
"dryRun": false,
|
||||
"source": "local_folder",
|
||||
"commandName": if command_name == "page.body.save" { "page.body.write" } else { command_name },
|
||||
"commandId": command_id,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"result": local_result
|
||||
});
|
||||
merge_result_extra(&mut result, result_extra);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let command = RuntimeCommandEnvelopeWire {
|
||||
name: command_name.into(),
|
||||
command_id: command_id.clone(),
|
||||
|
||||
@@ -344,6 +344,7 @@ impl PageAggregateBuilder {
|
||||
content: self.content,
|
||||
revision: self.revision,
|
||||
conflict_detection_key: self.conflict_detection_key,
|
||||
file_version: Value::Null,
|
||||
block_document: block_document.unwrap_or(Value::Null),
|
||||
block_projection_version,
|
||||
projection_source: "builder.content".into(),
|
||||
|
||||
@@ -5,7 +5,8 @@ use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
save_local_markdown_page, update_local_markdown_title, update_local_page_options,
|
||||
ensure_local_workspace_access, update_local_markdown_title, update_local_page_options,
|
||||
write_local_markdown_page_body,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||
@@ -46,6 +47,10 @@ pub struct DocumentSaveRequest {
|
||||
pub root_uri: Option<String>,
|
||||
pub revision: Option<u64>,
|
||||
pub conflict_detection_key: Option<String>,
|
||||
pub expected_file_version: Option<String>,
|
||||
pub base_content_hash: Option<String>,
|
||||
pub content_format: Option<String>,
|
||||
pub editor_source: Option<String>,
|
||||
pub editor_document: Option<Value>,
|
||||
pub content: Value,
|
||||
pub tiptap_document: Option<Value>,
|
||||
@@ -408,6 +413,7 @@ async fn proxy_next_documents_save(
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -514,6 +520,38 @@ pub async fn content(
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn page_body_write(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Json(body): Json<core_protocol::PageBodyWriteRequest>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let document_id = body.document_id.trim();
|
||||
if document_id.is_empty() {
|
||||
return Err(
|
||||
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||
.with_context(&context),
|
||||
);
|
||||
}
|
||||
if body.source_kind != core_protocol::WorkspaceSourceKind::LocalFolder {
|
||||
return Err(WebError::bad_request_code(
|
||||
"page_body_write_source_unsupported",
|
||||
"page.body.write 当前只支持 local_folder 本地写入",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let root_uri = body.root_uri.trim();
|
||||
if root_uri.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_root_required",
|
||||
"缺少本地文件夹 rootUri",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = write_local_markdown_page_body(&body)?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -536,12 +574,29 @@ pub async fn save(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let result = save_local_markdown_page(
|
||||
root_uri,
|
||||
document_id,
|
||||
body.conflict_detection_key.as_deref(),
|
||||
&body.content,
|
||||
)?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let expected_file_version = body
|
||||
.expected_file_version
|
||||
.as_deref()
|
||||
.or(body.conflict_detection_key.as_deref());
|
||||
let result = write_local_markdown_page_body(&core_protocol::PageBodyWriteRequest {
|
||||
document_id: document_id.to_string(),
|
||||
workspace_id: body.workspace_id.clone().unwrap_or_default(),
|
||||
source_kind: core_protocol::WorkspaceSourceKind::LocalFolder,
|
||||
root_uri: root_uri.to_string(),
|
||||
expected_file_version: expected_file_version.map(ToOwned::to_owned),
|
||||
base_content_hash: body.base_content_hash.clone(),
|
||||
content_format: body
|
||||
.content_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| "editorBlocks".into()),
|
||||
content: body.content.clone(),
|
||||
editor_source: body
|
||||
.editor_source
|
||||
.clone()
|
||||
.or_else(|| Some("documents/save-compat".into())),
|
||||
})?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
let effective_workspace_id =
|
||||
@@ -578,6 +633,7 @@ pub async fn save(
|
||||
"workspaceId": effective_workspace_id,
|
||||
"revision": body.revision,
|
||||
"conflictDetectionKey": body.conflict_detection_key,
|
||||
"expectedFileVersion": body.expected_file_version,
|
||||
"editorDocument": body.editor_document,
|
||||
"content": body.content,
|
||||
"tiptapDocument": body.tiptap_document,
|
||||
@@ -766,6 +822,8 @@ pub async fn title(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = update_local_markdown_title(root_uri, document_id, title)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
@@ -859,6 +917,8 @@ pub async fn options(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let result = update_local_page_options(root_uri, document_id, &body.options)?;
|
||||
return Ok(ok_response(&context, result));
|
||||
}
|
||||
@@ -1311,6 +1371,11 @@ mod tests {
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_test",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let document_id = "local-mdid:local-stable";
|
||||
|
||||
let title_response = app()
|
||||
@@ -1319,6 +1384,8 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/api/documents/title")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
@@ -1340,6 +1407,8 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/api/documents/save")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
@@ -1367,6 +1436,15 @@ mod tests {
|
||||
.await
|
||||
.expect("save response");
|
||||
assert_eq!(save_response.status(), StatusCode::OK);
|
||||
let save_body = to_bytes(save_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("save body");
|
||||
let save_payload: Value = serde_json::from_slice(&save_body).expect("save json");
|
||||
assert_eq!(
|
||||
save_payload["result"]["canonicalCommand"],
|
||||
"page.body.write"
|
||||
);
|
||||
assert_eq!(save_payload["result"]["compatCommand"], "page.body.save");
|
||||
|
||||
let options_response = app()
|
||||
.oneshot(
|
||||
@@ -1374,6 +1452,8 @@ mod tests {
|
||||
.method("POST")
|
||||
.uri("/api/documents/options")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
@@ -1406,4 +1486,82 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_documents_save_rejects_stale_expected_file_version() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-documents-expected-file-version-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# Old\n",
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_test",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let document_id = "local-mdid:expected-file-version";
|
||||
let aggregate = crate::routes::local_folder_source::resolve_local_markdown_page_aggregate(
|
||||
&root_uri,
|
||||
document_id,
|
||||
)
|
||||
.expect("aggregate");
|
||||
let stale_file_version = aggregate
|
||||
.body
|
||||
.conflict_detection_key
|
||||
.as_str()
|
||||
.expect("file version")
|
||||
.to_string();
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\nmnote_id: expected-file-version\ntitle: Versioned\n---\n# External\n",
|
||||
)
|
||||
.expect("external write");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/documents/save")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"documentId": document_id,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"expectedFileVersion": stale_file_version,
|
||||
"content": [
|
||||
{
|
||||
"id": "heading_1",
|
||||
"type": "heading",
|
||||
"props": { "level": 1 },
|
||||
"content": [{ "type": "text", "text": "Editor" }]
|
||||
}
|
||||
],
|
||||
"blockCount": 1
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("save response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
let markdown = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(markdown.contains("# External"));
|
||||
assert!(!markdown.contains("# Editor"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::load_local_folder_page_tree_snapshot;
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_page_tree_snapshot, local_access_policy_path_display,
|
||||
};
|
||||
use crate::routes::snapshot_support::load_sidebar_dataset;
|
||||
use crate::routes::web_shell::{
|
||||
build_document_panes_bootstrap_json, build_editor_bootstrap_json,
|
||||
@@ -153,6 +156,53 @@ pub async fn auth_entry(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn admin_access_policy_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
) -> Result<Response, WebError> {
|
||||
if !has_real_auth_context(&context) {
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.header(header::LOCATION, "/auth")
|
||||
.body(Body::empty())
|
||||
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
return Ok(response);
|
||||
}
|
||||
if !is_local_access_policy_admin_context(&context) {
|
||||
return Err(WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"local_access_policy_admin_required",
|
||||
"只有管理员可以访问目录授权页面",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let policy_path = local_access_policy_path_display();
|
||||
let content = crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::admin::AdminAccessPolicyPage workspace_name={workspace_name} policy_path={policy_path} />
|
||||
});
|
||||
let mut response = Html(format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>目录授权</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="admin" data-mnote-actor-id="{}">
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
content
|
||||
))
|
||||
.into_response();
|
||||
stamp_gateway_headers(response.headers_mut(), false);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn root_entry(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -178,6 +228,20 @@ pub async fn root_entry(
|
||||
let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID);
|
||||
let recent_page_id = normalize_optional_id(recent_page_id.as_deref());
|
||||
let default_workspace_name = format!("{} 的空间", state.config().dev_user_name);
|
||||
let should_render_local_first_landing = !is_local_folder
|
||||
&& query
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
&& query
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_none()
|
||||
&& requested_page_id.is_none();
|
||||
let (
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
@@ -195,6 +259,7 @@ pub async fn root_entry(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&context, root_uri)?;
|
||||
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let workspace_id = snapshot
|
||||
.dataset
|
||||
@@ -239,6 +304,26 @@ pub async fn root_entry(
|
||||
Some("local_folder".to_string()),
|
||||
Some(root_uri.to_string()),
|
||||
)
|
||||
} else if should_render_local_first_landing {
|
||||
let workspace_id = "local-first-entry".to_string();
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&json!({
|
||||
"workspaces": [{ "id": workspace_id, "name": "我的空间" }],
|
||||
"documents": [],
|
||||
}),
|
||||
&workspace_id,
|
||||
None,
|
||||
"我的空间",
|
||||
);
|
||||
(
|
||||
workspace_id,
|
||||
workspace_projection,
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let workspace_id =
|
||||
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
|
||||
@@ -303,6 +388,7 @@ pub async fn root_entry(
|
||||
.active_page_title
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let show_admin_access_policy = is_local_access_policy_admin_context(&context);
|
||||
let render_workspace_entry = || {
|
||||
crate::ssr::render_view(leptos::view! {
|
||||
<crate::ssr::pages::home::HomePage
|
||||
@@ -312,6 +398,7 @@ pub async fn root_entry(
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
active_page_id={active_page_id.clone()}
|
||||
active_page_title={active_page_title.clone()}
|
||||
show_admin_access_policy={show_admin_access_policy}
|
||||
/>
|
||||
})
|
||||
};
|
||||
@@ -360,6 +447,7 @@ pub async fn root_entry(
|
||||
workspace_name={workspace_name.clone()}
|
||||
workspace_sidebar_html={workspace_sidebar_html.clone()}
|
||||
page_subtree_json={page_subtree_json}
|
||||
show_admin_access_policy={show_admin_access_policy}
|
||||
/>
|
||||
});
|
||||
let body_extra = format!(
|
||||
@@ -387,13 +475,14 @@ pub async fn root_entry(
|
||||
<title>{}</title>
|
||||
<style>{}</style>
|
||||
</head>
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
||||
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace" data-mnote-actor-id="{}">
|
||||
{}
|
||||
{}
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(&html_title),
|
||||
crate::ssr::MNOTE_CSS,
|
||||
escape_html(context.auth.actor_id.as_str()),
|
||||
content,
|
||||
body_extra
|
||||
))
|
||||
@@ -1769,6 +1858,111 @@ mod tests {
|
||||
assert!(html.contains("mnote.document_panes_bootstrap.v1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_first_landing_without_convex() {
|
||||
let response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_shows_admin_access_policy_entry_only_for_admin_actor() {
|
||||
let admin_response =
|
||||
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("x-mnote-actor-id", "admin_real")
|
||||
.header("x-mnote-actor-type", "admin")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("admin response");
|
||||
|
||||
assert_eq!(admin_response.status(), StatusCode::OK);
|
||||
let admin_body = to_bytes(admin_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8");
|
||||
assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
|
||||
|
||||
let user_response =
|
||||
app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("user response");
|
||||
|
||||
assert_eq!(user_response.status(), StatusCode::OK);
|
||||
let user_body = to_bytes(user_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let user_html = String::from_utf8(user_body.to_vec()).expect("utf8");
|
||||
assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_access_policy_entry_requires_admin_actor() {
|
||||
let user_response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin/access-policy")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("user response");
|
||||
assert_eq!(user_response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let admin_response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin/access-policy")
|
||||
.header("x-mnote-actor-id", "admin_real")
|
||||
.header("x-mnote-actor-type", "admin")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("admin response");
|
||||
|
||||
assert_eq!(admin_response.status(), StatusCode::OK);
|
||||
let body = to_bytes(admin_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-validate-root-submit""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-create-grant-submit""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_renders_local_folder_without_debug_tree_route() {
|
||||
let root =
|
||||
@@ -1780,6 +1974,11 @@ mod tests {
|
||||
std::fs::write(root.join("plain.txt"), "plain\n").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -170,6 +170,31 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
}));
|
||||
return Err(error);
|
||||
}
|
||||
if !dry_run && !is_read_tool(&input.tool_name) && is_shared_read_scope(&input) {
|
||||
let error = WebError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"mnote_tool_shared_read_write_forbidden",
|
||||
"共享只读 AI 上下文不能执行写工具",
|
||||
)
|
||||
.with_context(&context)
|
||||
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||
.with_header(HEADER_HERMES_TOOL_OWNER, "mnote-web-hermes-tools");
|
||||
audit_push(json!({
|
||||
"phase": "failed",
|
||||
"traceId": trace_id,
|
||||
"sessionId": input.session_id,
|
||||
"runId": input.run_id,
|
||||
"toolCallId": tool_call_id,
|
||||
"toolName": input.tool_name,
|
||||
"workspaceId": workspace_id,
|
||||
"documentId": document_id,
|
||||
"actorId": input.actor_id,
|
||||
"status": error.status().as_u16(),
|
||||
"message": error.message(),
|
||||
"permissionLevel": "shared_read"
|
||||
}));
|
||||
return Err(error);
|
||||
}
|
||||
if let Some(cached) = idempotency_key.as_deref().and_then(idempotency_cache_get) {
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
@@ -301,6 +326,27 @@ fn is_read_tool(tool_name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_shared_read_scope(input: &ToolCallInput) -> bool {
|
||||
let direct = input
|
||||
.arg_string("permissionLevel")
|
||||
.or_else(|| input.arg_string("permission_level"));
|
||||
if matches!(direct.as_deref(), Some("shared_read")) {
|
||||
return true;
|
||||
}
|
||||
input
|
||||
.arg_value("aiAccessScope")
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("permissionLevel")
|
||||
.or_else(|| value.get("permission_level"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.as_deref()
|
||||
== Some("shared_read")
|
||||
}
|
||||
|
||||
fn audit_log() -> &'static Mutex<Vec<Value>> {
|
||||
static LOG: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
|
||||
LOG.get_or_init(|| Mutex::new(Vec::new()))
|
||||
@@ -882,6 +928,47 @@ mod tests {
|
||||
.any(|rule| rule["required"] == json!(["full_content"])));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_marks_write_tools_as_compat_fallbacks() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/hermes/tools/mnote/manifest")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::empty())
|
||||
.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");
|
||||
let tools = payload["manifest"]["tools"].as_array().expect("tools");
|
||||
let markdown_edit = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.doc.markdown_edit")
|
||||
.expect("markdown edit tool");
|
||||
let page_save = tools
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == "mnote.page.save")
|
||||
.expect("page save tool");
|
||||
|
||||
assert!(markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("兼容"));
|
||||
assert!(markdown_edit["description"]
|
||||
.as_str()
|
||||
.expect("description")
|
||||
.contains("agent 原生 patch/diff"));
|
||||
assert_eq!(
|
||||
page_save["annotations"]["requiresWritePermission"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_manifest_describes_block_tools_selection_scope() {
|
||||
let response = app()
|
||||
@@ -1129,6 +1216,8 @@ mod tests {
|
||||
assert_eq!(payload["toolName"], "mnote.doc.fetch");
|
||||
assert_eq!(payload["audit"]["effect"], "read");
|
||||
assert_eq!(payload["result"]["revision"], json!(7));
|
||||
assert_eq!(payload["result"]["conflictDetectionKey"], json!("doc_1:7"));
|
||||
assert_eq!(payload["result"]["fileVersion"], json!("doc_1:7"));
|
||||
assert_eq!(
|
||||
payload["result"]["blocks"][0]["blockId"],
|
||||
json!("heading_1")
|
||||
@@ -1140,6 +1229,90 @@ mod tests {
|
||||
.starts_with("pageRev:7:block:heading_1:hash:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource() {
|
||||
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")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.fetch",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_2",
|
||||
"sessionId": "sess_scope_read",
|
||||
"runId": "run_scope_read",
|
||||
"toolCallId": "call_scope_read",
|
||||
"traceId": "trace_scope_read",
|
||||
"args": {
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["doc_1"],
|
||||
"shareContext": {"shareId": "share_read_1"}
|
||||
}
|
||||
}
|
||||
})
|
||||
.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_ai_scope_read_forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_get_rejects_out_of_scope_ai_resource() {
|
||||
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")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.page.get",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_2",
|
||||
"sessionId": "sess_page_scope_read",
|
||||
"runId": "run_page_scope_read",
|
||||
"toolCallId": "call_page_scope_read",
|
||||
"traceId": "trace_page_scope_read",
|
||||
"args": {
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"allowedResourceIds": ["doc_1"],
|
||||
"shareContext": {"shareId": "share_read_1"}
|
||||
}
|
||||
}
|
||||
})
|
||||
.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_ai_scope_read_forbidden")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_doc_fetch_supports_selection_and_page_xml() {
|
||||
let response = app()
|
||||
@@ -2116,6 +2289,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_save_local_folder_writes_markdown_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-page-save-local-folder-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Old\n\n旧正文\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
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.page.save",
|
||||
"workspaceId": "local-ws-user-1",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"sessionId": "sess_page_save_local",
|
||||
"runId": "run_page_save_local",
|
||||
"toolCallId": "call_page_save_local",
|
||||
"traceId": "trace_page_save_local",
|
||||
"idempotencyKey": "idem_page_save_local",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"content": [
|
||||
{"type": "paragraph", "content": [{"type": "text", "text": "本地 page.save 写入"}]}
|
||||
]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
let payload: Value = serde_json::from_str(&text).expect("json");
|
||||
assert_eq!(payload["result"]["source"], "local_folder");
|
||||
assert_eq!(payload["result"]["commandName"], "page.body.write");
|
||||
let saved = fs::read_to_string(root.join("README.md")).expect("read");
|
||||
assert!(saved.contains("本地 page.save 写入"), "{saved}");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_page_save_dry_run_returns_diff_without_write() {
|
||||
let response = app()
|
||||
@@ -2264,6 +2501,74 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_shared_read_is_forbidden() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-markdown-edit-shared-read-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "原文\n").expect("write markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
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.doc.markdown_edit",
|
||||
"workspaceId": "local-ws-user-1",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_shared_read_md",
|
||||
"runId": "run_shared_read_md",
|
||||
"toolCallId": "call_shared_read_md",
|
||||
"traceId": "trace_shared_read_md",
|
||||
"idempotencyKey": "idem_shared_read_md",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"aiAccessScope": {
|
||||
"permissionLevel": "shared_read",
|
||||
"shareContext": {"shareId": "share_read_1"}
|
||||
},
|
||||
"operations": [{"search": "原文", "replace": "不应写入"}]
|
||||
}
|
||||
})
|
||||
.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")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("README.md")).expect("read"),
|
||||
"原文\n"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_reports_empty_block_mapping_before_apply() {
|
||||
// 7-27 修复后:即使 search 吃掉了块注释,也不会崩溃或泄露 apply_block_ops 错误。
|
||||
@@ -2406,6 +2711,165 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_online_page_body_save_carries_revision_conflict_key() {
|
||||
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")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.markdown_edit",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_markdown_precondition",
|
||||
"runId": "run_markdown_precondition",
|
||||
"toolCallId": "call_markdown_precondition",
|
||||
"traceId": "trace_markdown_precondition",
|
||||
"idempotencyKey": "idem_markdown_precondition",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"operations": [{"search": "第二段", "replace": "测试123"}]
|
||||
}
|
||||
})
|
||||
.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");
|
||||
let command_payload =
|
||||
&payload["result"]["applyResult"]["artifacts"]["commandLog"]["payload"];
|
||||
assert_eq!(command_payload["revision"], 7);
|
||||
assert_eq!(command_payload["conflictDetectionKey"], "doc_1:7");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_local_folder_writes_same_markdown_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-markdown-edit-local-folder-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("README.assets")).expect("create asset dir");
|
||||
fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: AI Local\n---\n# AI Local\n\n第一段\n\n\n",
|
||||
)
|
||||
.expect("write markdown");
|
||||
fs::write(root.join("README.assets").join("photo.png"), b"png").expect("write asset");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_1", &root_uri,
|
||||
)
|
||||
.expect("initialize 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.doc.markdown_edit",
|
||||
"workspaceId": "local-ws-test",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"actorId": "user_1",
|
||||
"sessionId": "sess_local_folder_md",
|
||||
"runId": "run_local_folder_md",
|
||||
"toolCallId": "call_local_folder_md",
|
||||
"traceId": "trace_local_folder_md",
|
||||
"idempotencyKey": "idem_local_folder_md",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"operations": [{"search": "第一段", "replace": "第一段已由 AI 修改"}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
if status != StatusCode::OK {
|
||||
panic!("status={status} headers={headers:?} body={text}");
|
||||
}
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["result"]["source"], "local_folder");
|
||||
assert_eq!(
|
||||
payload["result"]["applyResult"]["commandName"],
|
||||
"page.body.write"
|
||||
);
|
||||
let saved = fs::read_to_string(root.join("README.md")).expect("read markdown");
|
||||
assert!(saved.contains("第一段已由 AI 修改"));
|
||||
assert!(saved.contains("README.assets/photo.png"));
|
||||
assert!(!saved.contains("/api/media"));
|
||||
assert!(!saved.contains("assetId"));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_rejects_no_applied_operations() {
|
||||
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")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"toolName": "mnote.doc.markdown_edit",
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"sessionId": "sess_markdown_noop",
|
||||
"runId": "run_markdown_noop",
|
||||
"toolCallId": "call_markdown_noop",
|
||||
"traceId": "trace_markdown_noop",
|
||||
"idempotencyKey": "idem_markdown_noop",
|
||||
"dryRun": false,
|
||||
"args": {
|
||||
"operations": [{"search": "不存在的段落", "replace": "测试123"}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-error-code")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("mnote_markdown_edit_no_operations_applied")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_markdown_edit_rejects_selection_out_of_scope() {
|
||||
let response = app()
|
||||
@@ -2576,4 +3040,67 @@ mod tests {
|
||||
assert_eq!(payload["result"]["dryRun"], true);
|
||||
assert_eq!(payload["result"]["artifactType"], "summary");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hermes_tools_artifact_summary_local_folder_writes_sidecar_file() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-artifact-local-folder-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join(".mnote")).expect("metadata");
|
||||
fs::write(
|
||||
root.join(".mnote").join("workspace.json"),
|
||||
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files"]}"#,
|
||||
)
|
||||
.expect("manifest");
|
||||
fs::write(root.join("README.md"), "# Local\n").expect("markdown");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
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.artifact.create_summary",
|
||||
"workspaceId": "local-ws-user-1",
|
||||
"documentId": "local-md:README.md",
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"sessionId": "sess_artifact_local",
|
||||
"runId": "run_artifact_local",
|
||||
"toolCallId": "call_artifact_local",
|
||||
"traceId": "trace_artifact_local",
|
||||
"idempotencyKey": "idem_artifact_local",
|
||||
"dryRun": false,
|
||||
"args": {"summary": "本地摘要"}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
let payload: Value = serde_json::from_str(&text).expect("json");
|
||||
assert_eq!(payload["result"]["source"], "local_folder");
|
||||
let artifact_path = root
|
||||
.join(".mnote")
|
||||
.join("artifacts")
|
||||
.join("summary_local-md_README.md.json");
|
||||
let artifact = fs::read_to_string(&artifact_path).expect("artifact");
|
||||
assert!(artifact.contains("本地摘要"), "{artifact}");
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::decode_local_id_segment;
|
||||
use crate::routes::local_folder_source::{
|
||||
decode_local_id_segment, ensure_local_workspace_read_access,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||
@@ -9,7 +11,6 @@ use futures_util::stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
@@ -31,14 +32,8 @@ pub async fn local_folder_events(
|
||||
),
|
||||
WebError,
|
||||
> {
|
||||
let root = parse_file_root_uri(&query.root_uri)?;
|
||||
let canonical_root = root.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let canonical_root = ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let document_relative_path = query
|
||||
.document_id
|
||||
.as_deref()
|
||||
@@ -105,17 +100,6 @@ pub async fn local_folder_events(
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_file_root_uri(root_uri: &str) -> Result<PathBuf, WebError> {
|
||||
let trimmed = root_uri.trim();
|
||||
let Some(path) = trimmed.strip_prefix("file://") else {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_root_invalid",
|
||||
"本地文件夹 rootUri 必须是 file:// URI",
|
||||
));
|
||||
};
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<String> {
|
||||
let trimmed = document_id.trim();
|
||||
let encoded = trimmed.strip_prefix("local-md:")?;
|
||||
@@ -156,10 +140,4 @@ mod tests {
|
||||
Some("docs/README.md")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_file_root_uri_requires_file_scheme() {
|
||||
assert!(parse_file_root_uri("file:///tmp/example").is_ok());
|
||||
assert!(parse_file_root_uri("/tmp/example").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,11 @@ mod tree;
|
||||
pub(crate) mod web_shell;
|
||||
mod ws;
|
||||
|
||||
pub(crate) use local_folder_source::{
|
||||
ensure_local_path_read_access, ensure_local_workspace_access, update_local_markdown_title,
|
||||
update_local_page_options, write_local_markdown_page_body,
|
||||
};
|
||||
|
||||
use crate::app::AppState;
|
||||
use axum::routing::{any, delete, get, post, put};
|
||||
use axum::Router;
|
||||
@@ -42,6 +47,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/", get(gateway::root_entry))
|
||||
.route("/trash", get(gateway::trash_entry))
|
||||
.route("/favicon.ico", get(gateway::favicon))
|
||||
.route(
|
||||
"/admin/access-policy",
|
||||
get(gateway::admin_access_policy_entry),
|
||||
)
|
||||
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
||||
.route("/search", get(search::shell))
|
||||
.route(
|
||||
@@ -88,6 +97,22 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/auth/whoami", get(session::session))
|
||||
.route("/api/auth/mnote-web-token", get(session::session))
|
||||
.route("/api/auth/session/refresh", post(session::refresh_session))
|
||||
.route(
|
||||
"/api/admin/access-policy",
|
||||
get(local_folder_source::get_local_access_policy),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy/validate-root",
|
||||
post(local_folder_source::validate_local_access_root),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy/grants",
|
||||
post(local_folder_source::create_local_access_grant),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/access-policy/grants/{grant_id}",
|
||||
delete(local_folder_source::delete_local_access_grant),
|
||||
)
|
||||
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
|
||||
.route(
|
||||
"/api/page-ai/block-edit-workflow",
|
||||
@@ -134,6 +159,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
.route("/api/documents/options", post(documents::options))
|
||||
.route("/api/documents/save", post(documents::save))
|
||||
.route("/api/page-body/write", post(documents::page_body_write))
|
||||
.route(
|
||||
"/api/documents/runtime/transform",
|
||||
post(editor::transform_runtime_snapshot),
|
||||
@@ -160,6 +186,18 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/local-folder/events",
|
||||
get(local_folder_events::local_folder_events),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/workspaces/default",
|
||||
post(local_folder_source::create_default_local_workspace),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/assets/upload",
|
||||
post(local_folder_source::upload_local_markdown_asset),
|
||||
)
|
||||
.route(
|
||||
"/api/local-folder/files/open",
|
||||
get(local_folder_source::open_local_file),
|
||||
)
|
||||
.route(
|
||||
"/api/tree/runtime/reduce",
|
||||
post(tree::reduce_tree_shell_runtime),
|
||||
|
||||
@@ -67,7 +67,8 @@ pub async fn block_edit_workflow(
|
||||
// 退役 direct_block_edit_operations:不再走正则抠「」的本地快路径。
|
||||
// 所有块编辑请求统一走模型 → search/replace 对 → doc_markdown_edit。
|
||||
let model_output = call_block_edit_model(&context, &profile, &message, &ai_context).await?;
|
||||
let markdown_operations = extract_markdown_operations_from_model_text(&model_output)?;
|
||||
let markdown_plan = extract_markdown_plan_from_model_text(&model_output)?;
|
||||
let markdown_operations = markdown_plan.operations.clone();
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
run_id = %run_id,
|
||||
@@ -116,6 +117,8 @@ pub async fn block_edit_workflow(
|
||||
tool_name: "mnote.doc.markdown_edit".into(),
|
||||
workspace_id: Some(workspace_id.clone()),
|
||||
document_id: Some(document_id.clone()),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
actor_id: Some(actor_id),
|
||||
profile: Some(profile),
|
||||
session_id: Some(session_id),
|
||||
@@ -153,7 +156,9 @@ pub async fn block_edit_workflow(
|
||||
"operations": markdown_operations,
|
||||
"applyResult": apply_result,
|
||||
"toolExecution": tool_response,
|
||||
"message": "已通过页面 markdown 编辑快路径完成写入。",
|
||||
"message": markdown_plan
|
||||
.summary
|
||||
.unwrap_or_else(|| "已通过页面 markdown 编辑快路径完成写入。".into()),
|
||||
"timingsMs": {
|
||||
"total": started.elapsed().as_millis(),
|
||||
"apply": apply_ms
|
||||
@@ -162,21 +167,35 @@ pub async fn block_edit_workflow(
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
|
||||
struct MarkdownEditPlan {
|
||||
operations: Vec<Value>,
|
||||
summary: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_markdown_plan_from_model_text(text: &str) -> Result<MarkdownEditPlan, WebError> {
|
||||
let parsed = parse_model_json(text)?;
|
||||
if let Some(content) = parsed
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return extract_markdown_operations_from_model_text(content);
|
||||
return extract_markdown_plan_from_model_text(content);
|
||||
}
|
||||
let summary = parsed
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
if let Some(operations) = parsed.get("operations").and_then(Value::as_array) {
|
||||
// 新格式:直接是 search/replace 对
|
||||
if operations
|
||||
.iter()
|
||||
.any(|op| op.get("search").is_some() || op.get("replace").is_some())
|
||||
{
|
||||
return Ok(operations.clone());
|
||||
return Ok(MarkdownEditPlan {
|
||||
operations: operations.clone(),
|
||||
summary,
|
||||
});
|
||||
}
|
||||
// 旧格式(block ops):转换为 search/replace 对
|
||||
let converted: Vec<Value> = operations
|
||||
@@ -204,7 +223,10 @@ fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>,
|
||||
})
|
||||
.collect();
|
||||
if !converted.is_empty() {
|
||||
return Ok(converted);
|
||||
return Ok(MarkdownEditPlan {
|
||||
operations: converted,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(WebError::bad_request_code(
|
||||
@@ -330,7 +352,7 @@ async fn call_block_edit_model(
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 mnote 页面编辑 workflow。只输出 JSON:{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search(要搜索替换的原文片段,从 page_text 中精确复制)和 replace(替换后的新文本)。禁止输出解释文字。\n\n示例:用户说\"把第一段改成你好\",若 page_text 第一段是\"旧内容\",则输出:{\"operations\":[{\"search\":\"旧内容\",\"replace\":\"你好\"}],\"summary\":\"替换了第一段\"}"
|
||||
"content": "你是 mnote 页面编辑 workflow。只输出 JSON:{\"operations\":[...] ,\"summary\":\"...\"}。每个 operation 包含 search(要搜索替换的原文片段,从 page_text 中精确复制)和 replace(替换后的新文本)。summary 要简短回答用户的读取/检查要求和写入结果;如果用户要求读取某段,summary 必须包含你从 page_text 读取到的原文。禁止输出解释文字。\n\n示例:用户说\"检查第一段并把第二段改成测试123\",若 page_text 第一段是\"第一段\",则输出:{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段:第一段;已修改第二段为:测试123\"}"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
@@ -684,6 +706,28 @@ mod tests {
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_read_and_edit_mock_model_server() -> String {
|
||||
async fn completions() -> Json<Value> {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"已读取第一段:第一段;已修改第二段为:测试123\"}"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind read-and-edit mock model");
|
||||
let addr = listener.local_addr().expect("mock model addr");
|
||||
let server = Router::new().route("/chat/completions", post(completions));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, server).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_operations_from_fenced_model_json() {
|
||||
let operations = extract_operations_from_model_text(
|
||||
@@ -846,4 +890,78 @@ mod tests {
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_read_and_edit_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-read-summary-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
format!(
|
||||
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\n"
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/page-ai/block-edit-workflow")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"message": "检查你是否能读取到本页第一段,同时请修改第二段为:测试123",
|
||||
"profile": "mnoteai",
|
||||
"sessionId": "sess_page_ai_read_summary",
|
||||
"runId": "run_page_ai_read_summary",
|
||||
"traceId": "trace_page_ai_read_summary",
|
||||
"pageContext": {
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"pageText": "第一段\n\n第二段",
|
||||
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
|
||||
"contextBlocks": [
|
||||
{"blockId": "p_1", "text": "第一段"},
|
||||
{"blockId": "p_2", "text": "第二段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
.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["ok"], true);
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("已读取第一段:第一段"));
|
||||
assert!(payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("测试123"));
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
execute_local_tree_command, load_local_folder_file_tree_snapshot,
|
||||
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
|
||||
local_workspace_id_from_root_uri,
|
||||
ensure_local_workspace_access, ensure_local_workspace_read_access, execute_local_tree_command,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
local_folder_watch_revision, local_workspace_id_from_root_uri,
|
||||
};
|
||||
use crate::routes::query_support::{
|
||||
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
|
||||
@@ -6122,6 +6122,8 @@ pub async fn local_folder_watch(
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFolderWatchQuery>,
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let revision = local_folder_watch_revision(&query.root_uri)?;
|
||||
Ok(json_response(
|
||||
&context,
|
||||
@@ -6218,6 +6220,8 @@ pub async fn tree_shell(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(&effective_context, root_uri)
|
||||
.map_err(|error| error.with_context(&effective_context))?;
|
||||
(
|
||||
local_workspace_id_from_root_uri(root_uri)?,
|
||||
if mode == "filetree" {
|
||||
@@ -6839,6 +6843,8 @@ pub async fn tree_command(
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
.with_context(&context)
|
||||
})?;
|
||||
ensure_local_workspace_access(&context, root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let execution = execute_local_tree_command(
|
||||
root_uri,
|
||||
action,
|
||||
@@ -6959,7 +6965,7 @@ mod tests {
|
||||
use crate::context::RequestContext;
|
||||
use crate::routes::command_support::build_runtime_command_plan;
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -6984,6 +6990,30 @@ mod tests {
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
.layer(axum::middleware::from_fn(inject_test_actor))
|
||||
}
|
||||
|
||||
async fn inject_test_actor(
|
||||
mut request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-id")
|
||||
.or_insert(HeaderValue::from_static("user_test"));
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-type")
|
||||
.or_insert(HeaderValue::from_static("user"));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
actor_id,
|
||||
&format!("file://{}", root.display()),
|
||||
)
|
||||
.expect("init local workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7196,12 +7226,15 @@ mod tests {
|
||||
std::fs::write(root.join("image.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7236,12 +7269,15 @@ mod tests {
|
||||
std::fs::write(root.join("asset.txt"), "asset").expect("write local asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=filetree&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7277,12 +7313,15 @@ mod tests {
|
||||
std::fs::write(root.join("image.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/tree?mode=page&sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -7311,6 +7350,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
|
||||
let create_response = app()
|
||||
.oneshot(
|
||||
@@ -7498,6 +7538,7 @@ mod tests {
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create docs");
|
||||
std::fs::write(root.join("docs").join("photo.png"), b"png").expect("write asset");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let asset_id = "local:asset:docs/photo.png";
|
||||
|
||||
let delete_response = app()
|
||||
@@ -7608,6 +7649,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
@@ -7653,6 +7695,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_command_local_folder_rejects_non_owner_root() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-tree-owner-denied-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
init_local_workspace(&root, "owner_user");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/tree/commands")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "other_user")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"action":"delete","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"local-md:README.md"}}"#
|
||||
)))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let status = response.status();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("error json");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "local_workspace_access_denied");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_shell_embeds_renderer_input_contract() {
|
||||
let filetree_response = app()
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::routes::documents::{
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
ensure_local_workspace_read_access, is_local_access_policy_admin_context,
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
@@ -200,6 +201,7 @@ pub async fn document_page_shell(
|
||||
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
|
||||
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
|
||||
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
|
||||
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
|
||||
/>
|
||||
});
|
||||
let hermes_settings_config_script = render_hermes_settings_config_script();
|
||||
@@ -327,21 +329,27 @@ pub(crate) fn build_editor_bootstrap_json_with_ids(
|
||||
page_aggregate_script_id: &str,
|
||||
pane_role: &str,
|
||||
) -> String {
|
||||
let normalized_source_kind = source_kind
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("convex_workspace");
|
||||
let save_endpoint = if normalized_source_kind == "local_folder" {
|
||||
"/api/page-body/write"
|
||||
} else {
|
||||
"/api/documents/save"
|
||||
};
|
||||
serde_json::to_string(&json!({
|
||||
"schema": "mnote.editor_bootstrap.v1",
|
||||
"documentId": aggregate.identity.document_id,
|
||||
"workspaceId": aggregate.identity.workspace_id,
|
||||
"paneRole": pane_role,
|
||||
"sourceKind": source_kind
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("convex_workspace"),
|
||||
"sourceKind": normalized_source_kind,
|
||||
"rootUri": root_uri
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(""),
|
||||
"pageAggregateScriptId": page_aggregate_script_id,
|
||||
"saveEndpoint": "/api/documents/save",
|
||||
"saveEndpoint": save_endpoint,
|
||||
"titleEndpoint": "/api/documents/title",
|
||||
"editorHostKind": "leptos_tiptap_island",
|
||||
"assetMode": "rust-web-leptos-tiptap-spike-island-bundle",
|
||||
@@ -1023,20 +1031,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
const text = typeof child.text === 'string' ? child.text : '';
|
||||
if (!text) return [];
|
||||
const styles = {};
|
||||
const marks = [];
|
||||
for (const mark of Array.isArray(child.marks) ? child.marks : []) {
|
||||
if (mark?.type === 'bold') styles.bold = true;
|
||||
if (mark?.type === 'italic') styles.italic = true;
|
||||
if (mark?.type === 'underline') styles.underline = true;
|
||||
if (mark?.type === 'strike') styles.strike = true;
|
||||
if (mark?.type === 'code') styles.code = true;
|
||||
if (mark?.type === 'bold') {
|
||||
styles.bold = true;
|
||||
marks.push('bold');
|
||||
}
|
||||
if (mark?.type === 'italic') {
|
||||
styles.italic = true;
|
||||
marks.push('italic');
|
||||
}
|
||||
if (mark?.type === 'underline') {
|
||||
styles.underline = true;
|
||||
marks.push('underline');
|
||||
}
|
||||
if (mark?.type === 'strike') {
|
||||
styles.strike = true;
|
||||
marks.push('strike');
|
||||
}
|
||||
if (mark?.type === 'code') {
|
||||
styles.code = true;
|
||||
marks.push('code');
|
||||
}
|
||||
if (mark?.type === 'link') {
|
||||
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
|
||||
if (href) styles.link = href;
|
||||
}
|
||||
}
|
||||
return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }];
|
||||
return [{
|
||||
payload: { type: 'text', text, ...(marks.length ? { marks } : {}) },
|
||||
attrs: Object.keys(styles).length ? { styles } : {},
|
||||
type: 'text',
|
||||
text,
|
||||
...(Object.keys(styles).length ? { styles } : {}),
|
||||
}];
|
||||
}
|
||||
if (child?.type === 'hardBreak') return [{ type: 'text', text: '\n' }];
|
||||
if (child?.type === 'hardBreak') return [{ payload: { type: 'hard_break' }, attrs: {}, type: 'text', text: '\n' }];
|
||||
return inlineTextNodes(child);
|
||||
});
|
||||
}
|
||||
@@ -1133,9 +1163,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: Array.isArray(block.contentNodes)
|
||||
? block.contentNodes.map((node) => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const text = typeof node.text === 'string' ? node.text : '';
|
||||
const payload = node.payload && typeof node.payload === 'object' ? node.payload : {};
|
||||
const text = typeof payload.text === 'string'
|
||||
? payload.text
|
||||
: payload.type === 'hard_break'
|
||||
? '\n'
|
||||
: typeof node.text === 'string'
|
||||
? node.text
|
||||
: '';
|
||||
if (!text) return null;
|
||||
return { type: 'text', text, ...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}) };
|
||||
const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {};
|
||||
const styles = attrs.styles && typeof attrs.styles === 'object'
|
||||
? attrs.styles
|
||||
: node.styles && typeof node.styles === 'object'
|
||||
? node.styles
|
||||
: null;
|
||||
const marks = Array.isArray(payload.marks) ? payload.marks : [];
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
...(styles ? { styles } : {}),
|
||||
...(marks.length ? { marks } : {}),
|
||||
};
|
||||
}).filter(Boolean)
|
||||
: '',
|
||||
}));
|
||||
@@ -1144,7 +1193,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
? body.conflictDetectionKey
|
||||
: typeof body?.conflict_detection_key === 'string'
|
||||
? body.conflict_detection_key
|
||||
: null;
|
||||
: typeof body?.fileVersion === 'string'
|
||||
? body.fileVersion
|
||||
: typeof body?.file_version === 'string'
|
||||
? body.file_version
|
||||
: null;
|
||||
|
||||
const revisionFromConflictKey = (value) => {
|
||||
const match = String(value || '').match(/:(\d+)$/);
|
||||
@@ -1206,7 +1259,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
sourceKind: descriptor.sourceKind || 'convex_workspace',
|
||||
rootUri: descriptor.rootUri || '',
|
||||
pageAggregateScriptId: paneRole === 'secondary' ? '__MNOTE_SECONDARY_PAGE_AGGREGATE__' : '__MNOTE_PAGE_AGGREGATE__',
|
||||
saveEndpoint: '/api/documents/save',
|
||||
saveEndpoint: (descriptor.sourceKind || 'convex_workspace') === 'local_folder'
|
||||
? '/api/page-body/write'
|
||||
: '/api/documents/save',
|
||||
titleEndpoint: '/api/documents/title',
|
||||
editorHostKind: 'leptos_tiptap_island',
|
||||
});
|
||||
@@ -1469,7 +1524,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
});
|
||||
};
|
||||
|
||||
const sessionPlainText = (session) => flattenText(session.currentTiptapDocument).replace(/\s+/g, ' ').trim();
|
||||
const normalizePlainText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const sessionPlainText = (session) => {
|
||||
const liveText = sessionViews(session)
|
||||
.map((view) => normalizePlainText(currentEditorText(view)))
|
||||
.find((text) => text);
|
||||
if (liveText) return liveText;
|
||||
return normalizePlainText(flattenText(session.currentTiptapDocument));
|
||||
};
|
||||
|
||||
const sessionHasRecentExternalSignal = (session) => (
|
||||
session.sourceKind === 'local_folder'
|
||||
@@ -1484,6 +1547,196 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
&& (Date.now() - session.lastUserInputAt) < 1500
|
||||
);
|
||||
|
||||
const fetchLatestSessionAggregate = async (session) => {
|
||||
const response = await fetch(pageAggregateUrl({
|
||||
documentId: session.documentId,
|
||||
sourceKind: session.sourceKind,
|
||||
workspaceId: session.workspaceId,
|
||||
rootUri: session.rootUri,
|
||||
}).toString(), {
|
||||
cache: 'no-store',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('conflict_latest_fetch_failed_' + response.status);
|
||||
const payload = await response.json();
|
||||
const nextAggregate = payload?.result;
|
||||
if (!nextAggregate || typeof nextAggregate !== 'object') {
|
||||
throw new Error('conflict_latest_missing_aggregate');
|
||||
}
|
||||
return nextAggregate;
|
||||
};
|
||||
|
||||
const aggregatePlainText = (aggregate) => {
|
||||
const body = aggregate?.body || {};
|
||||
return flattenText(toTiptapDocument(body.content)).replace(/\n{3,}/g, '\n\n').trim();
|
||||
};
|
||||
|
||||
const clearSessionConflictSurface = (session) => {
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
if (!(host instanceof HTMLElement)) return;
|
||||
host.querySelectorAll('[data-testid="mnote-editor-conflict-panel"]').forEach((node) => node.remove());
|
||||
});
|
||||
};
|
||||
|
||||
const applyAggregateSnapshotToSession = (session, nextAggregate, source) => {
|
||||
const nextBody = nextAggregate?.body || {};
|
||||
const nextPermissions = nextAggregate?.head?.permissions || {};
|
||||
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
|
||||
const nextTiptapDocument = toTiptapDocument(nextBody.content);
|
||||
const nextSerialized = JSON.stringify(nextTiptapDocument);
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
syncPageAggregateScript(session, nextAggregate);
|
||||
session.title = nextAggregate?.head?.title || session.title;
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
session.lastPersistedSerialized = nextSerialized;
|
||||
session.revision = nextRevision;
|
||||
session.conflictDetectionKey = nextConflictKey;
|
||||
session.lastExternalConflictDetectionKey = nextConflictKey || '';
|
||||
session.readOnly = Boolean(nextPermissions.readOnly);
|
||||
session.dirty = false;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.lastUserInputAt = 0;
|
||||
clearSessionConflictSurface(session);
|
||||
sessionViews(session).forEach((view) => {
|
||||
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-conflict-resolved');
|
||||
});
|
||||
setSessionStatus(session, 'synced-external-change');
|
||||
};
|
||||
|
||||
const openConflictDiffPanel = async (session, panel) => {
|
||||
const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]');
|
||||
if (!(diffPanel instanceof HTMLElement)) return;
|
||||
diffPanel.hidden = false;
|
||||
diffPanel.replaceChildren();
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'mnote-conflict-diff-status';
|
||||
loading.textContent = '正在读取磁盘版本...';
|
||||
diffPanel.appendChild(loading);
|
||||
try {
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
diffPanel.replaceChildren();
|
||||
const current = document.createElement('pre');
|
||||
current.setAttribute('data-testid', 'mnote-conflict-current-text');
|
||||
current.textContent = sessionPlainText(session) || '(当前编辑器为空)';
|
||||
const disk = document.createElement('pre');
|
||||
disk.setAttribute('data-testid', 'mnote-conflict-disk-text');
|
||||
disk.textContent = aggregatePlainText(latest) || '(磁盘版本为空)';
|
||||
const currentTitle = document.createElement('h3');
|
||||
currentTitle.textContent = '当前编辑器版本';
|
||||
const diskTitle = document.createElement('h3');
|
||||
diskTitle.textContent = '磁盘版本';
|
||||
const currentBox = document.createElement('section');
|
||||
currentBox.append(currentTitle, current);
|
||||
const diskBox = document.createElement('section');
|
||||
diskBox.append(diskTitle, disk);
|
||||
diffPanel.append(currentBox, diskBox);
|
||||
} catch (error) {
|
||||
loading.textContent = error instanceof Error ? error.message : String(error);
|
||||
diffPanel.replaceChildren(loading);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptDiskVersion = async (session) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk');
|
||||
};
|
||||
|
||||
const keepCurrentEditorVersion = async (session) => {
|
||||
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
|
||||
const latest = await fetchLatestSessionAggregate(session);
|
||||
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
|
||||
if (hydrateView) {
|
||||
const liveText = normalizePlainText(currentEditorText(hydrateView));
|
||||
if (liveText) {
|
||||
session.currentTiptapDocument = hydrateMindmapAttrsFromDom(
|
||||
textToTiptapDocument(liveText),
|
||||
hydrateView.runtimeDescriptor.root,
|
||||
);
|
||||
}
|
||||
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
|
||||
}
|
||||
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
|
||||
if (nextKey) {
|
||||
session.conflictDetectionKey = nextKey;
|
||||
session.lastExternalConflictDetectionKey = nextKey;
|
||||
}
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
session.saving = false;
|
||||
session.dirty = true;
|
||||
clearSessionConflictSurface(session);
|
||||
await persistSession(session);
|
||||
};
|
||||
|
||||
const renderSessionConflictSurface = (session, message) => {
|
||||
clearSessionConflictSurface(session);
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
if (!(host instanceof HTMLElement)) return;
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'mnote-editor-conflict-panel';
|
||||
panel.setAttribute('data-testid', 'mnote-editor-conflict-panel');
|
||||
panel.setAttribute('role', 'status');
|
||||
panel.setAttribute('aria-live', 'polite');
|
||||
|
||||
const heading = document.createElement('h2');
|
||||
heading.textContent = '文件冲突';
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message || externalConflictMessage;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'mnote-conflict-meta';
|
||||
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:本地文件变更`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'mnote-conflict-actions';
|
||||
const acceptDisk = document.createElement('button');
|
||||
acceptDisk.type = 'button';
|
||||
acceptDisk.textContent = '接受磁盘版本';
|
||||
acceptDisk.setAttribute('data-testid', 'mnote-conflict-accept-disk');
|
||||
const keepCurrent = document.createElement('button');
|
||||
keepCurrent.type = 'button';
|
||||
keepCurrent.textContent = '保留当前编辑器版本';
|
||||
keepCurrent.setAttribute('data-testid', 'mnote-conflict-keep-current');
|
||||
const openDiff = document.createElement('button');
|
||||
openDiff.type = 'button';
|
||||
openDiff.textContent = '打开 diff';
|
||||
openDiff.setAttribute('data-testid', 'mnote-conflict-open-diff');
|
||||
actions.append(acceptDisk, keepCurrent, openDiff);
|
||||
const diffPanel = document.createElement('div');
|
||||
diffPanel.className = 'mnote-conflict-diff-panel';
|
||||
diffPanel.setAttribute('data-testid', 'mnote-conflict-diff-panel');
|
||||
diffPanel.hidden = true;
|
||||
panel.append(heading, text, meta, actions, diffPanel);
|
||||
|
||||
acceptDisk.addEventListener('click', () => {
|
||||
acceptDiskVersion(session).catch((error) => {
|
||||
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
|
||||
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
keepCurrent.addEventListener('click', () => {
|
||||
keepCurrentEditorVersion(session).catch((error) => {
|
||||
setSessionStatus(session, 'external-change-conflict', error instanceof Error ? error.message : String(error));
|
||||
renderSessionConflictSurface(session, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
});
|
||||
openDiff.addEventListener('click', () => {
|
||||
openConflictDiffPanel(session, panel);
|
||||
});
|
||||
|
||||
const header = host.querySelector('.document-shell-header');
|
||||
if (header && header.parentNode) {
|
||||
header.parentNode.insertBefore(panel, header.nextSibling);
|
||||
} else {
|
||||
host.prepend(panel);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const markSessionExternalConflict = (session, message) => {
|
||||
session.externalChangePending = false;
|
||||
session.hasExternalConflict = true;
|
||||
@@ -1492,6 +1745,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
session.saveTimer = 0;
|
||||
}
|
||||
setSessionStatus(session, 'external-change-conflict', message || externalConflictMessage);
|
||||
renderSessionConflictSurface(session, message || externalConflictMessage);
|
||||
};
|
||||
|
||||
const queueSessionSave = (session) => {
|
||||
@@ -1521,21 +1775,28 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
try {
|
||||
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
|
||||
const content = legacyBlocksFromEditorDocument(editorDocument);
|
||||
const response = await fetch(session.saveEndpoint || '/api/documents/save', {
|
||||
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
|
||||
const savePayload = {
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
expectedFileVersion: session.conflictDetectionKey,
|
||||
contentFormat: 'editorBlocks',
|
||||
editorSource: 'tiptap',
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
};
|
||||
if (session.sourceKind !== 'local_folder') {
|
||||
savePayload.conflictDetectionKey = session.conflictDetectionKey;
|
||||
}
|
||||
const response = await fetch(saveEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: session.documentId,
|
||||
workspaceId: session.workspaceId,
|
||||
sourceKind: session.sourceKind,
|
||||
rootUri: session.rootUri,
|
||||
revision: session.revision,
|
||||
conflictDetectionKey: session.conflictDetectionKey,
|
||||
editorDocument,
|
||||
content,
|
||||
tiptapDocument: session.currentTiptapDocument,
|
||||
blockCount: editorDocument.blocks.length,
|
||||
}),
|
||||
body: JSON.stringify(savePayload),
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || !result || result.ok !== true) {
|
||||
@@ -1550,6 +1811,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (typeof saved.conflictDetectionKey === 'string' && saved.conflictDetectionKey.trim()) {
|
||||
session.conflictDetectionKey = saved.conflictDetectionKey.trim();
|
||||
}
|
||||
if (typeof saved.fileVersion === 'string' && saved.fileVersion.trim()) {
|
||||
session.conflictDetectionKey = saved.fileVersion.trim();
|
||||
}
|
||||
if (session.conflictDetectionKey) session.lastExternalConflictDetectionKey = session.conflictDetectionKey;
|
||||
session.hasExternalConflict = false;
|
||||
session.externalChangePending = false;
|
||||
@@ -1912,6 +2176,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
lastPersistedSerialized: JSON.stringify(tiptapDocument),
|
||||
revision: pageBodyRevision && pageBodyRevision > 0 ? pageBodyRevision : keyRevision,
|
||||
conflictDetectionKey,
|
||||
fileVersion: typeof pageBody.fileVersion === 'string' ? pageBody.fileVersion : conflictDetectionKey,
|
||||
lastExternalConflictDetectionKey: conflictDetectionKey || '',
|
||||
readOnly: Boolean(permissions.readOnly),
|
||||
dirty: false,
|
||||
@@ -2633,6 +2898,8 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
ensure_local_workspace_read_access(context, root_uri)
|
||||
.map_err(|error| error.with_context(context))?;
|
||||
return resolve_local_markdown_page_aggregate(root_uri, document_id);
|
||||
}
|
||||
|
||||
@@ -2937,7 +3204,7 @@ mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -2970,6 +3237,19 @@ mod tests {
|
||||
},
|
||||
"documents:getContent": {
|
||||
"content": [{"id": "block_1", "type": "paragraph", "content": []}],
|
||||
"editorDocument": {
|
||||
"documentId": "doc_1",
|
||||
"rootBlockIds": ["editor_1"],
|
||||
"blocks": [{
|
||||
"blockId": "editor_1",
|
||||
"blockType": "paragraph",
|
||||
"contentNodes": [{
|
||||
"payload": {"type": "text", "text": "来自 editorDocument 的正文"},
|
||||
"attrs": {}
|
||||
}],
|
||||
"childBlockIds": []
|
||||
}]
|
||||
},
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7",
|
||||
"pageSubtree": {"rootNodeId": "doc_1", "outline": []}
|
||||
@@ -2982,6 +3262,30 @@ mod tests {
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
.layer(axum::middleware::from_fn(inject_test_actor))
|
||||
}
|
||||
|
||||
async fn inject_test_actor(
|
||||
mut request: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-id")
|
||||
.or_insert(HeaderValue::from_static("user_test"));
|
||||
request
|
||||
.headers_mut()
|
||||
.entry("x-mnote-actor-type")
|
||||
.or_insert(HeaderValue::from_static("user"));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn init_local_workspace(root: &std::path::Path, actor_id: &str) {
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
actor_id,
|
||||
&format!("file://{}", root.display()),
|
||||
)
|
||||
.expect("init local workspace");
|
||||
}
|
||||
|
||||
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
|
||||
@@ -3137,6 +3441,18 @@ mod tests {
|
||||
assert_eq!(payload["result"]["projectionVersion"], 1);
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["projectionSource"],
|
||||
"editorDocument"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["blockDocument"]["rootBlockIds"][0],
|
||||
"editor_1"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["result"]["body"]["blockDocument"]["blocks"][0]["text"],
|
||||
"来自 editorDocument 的正文"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3246,12 +3562,15 @@ mod tests {
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3297,12 +3616,15 @@ mod tests {
|
||||
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3325,6 +3647,7 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("Local Shell"));
|
||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
|
||||
assert!(html.contains("Child Page"));
|
||||
assert!(html.contains("asset.png"));
|
||||
assert!(html.contains("data-row-kind=\"markdown\""));
|
||||
@@ -3349,6 +3672,10 @@ mod tests {
|
||||
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
|
||||
assert!(html.contains("command: 'replaceContent'"));
|
||||
assert!(html.contains("external-change-conflict"));
|
||||
assert!(html.contains("mnote-editor-conflict-panel"));
|
||||
assert!(html.contains("mnote-conflict-accept-disk"));
|
||||
assert!(html.contains("mnote-conflict-keep-current"));
|
||||
assert!(html.contains("mnote-conflict-open-diff"));
|
||||
assert!(!html.contains(
|
||||
"setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);"
|
||||
));
|
||||
@@ -3420,12 +3747,15 @@ mod tests {
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
init_local_workspace(&root, "user_test");
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:docs~2Fblocks.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.header("x-mnote-actor-id", "user_test")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
@@ -3495,6 +3825,11 @@ mod tests {
|
||||
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
||||
assert!(html.contains("styles.link = href"));
|
||||
assert!(html.contains("contentNodes.map((node) => {"));
|
||||
assert!(html.contains("payload: { type: 'text', text"));
|
||||
assert!(html.contains("typeof payload.text === 'string'"));
|
||||
assert!(html.contains("payload.type === 'hard_break'"));
|
||||
assert!(html.contains("typeof body?.fileVersion === 'string'"));
|
||||
assert!(html.contains("expectedFileVersion: session.conflictDetectionKey"));
|
||||
assert!(html.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
|
||||
assert!(html.contains("blockType: 'mindmap'"));
|
||||
assert!(html.contains("props: mindmapPropsFromAttrs(node?.attrs, blockId)"));
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! MNOTE 管理员目录授权页面组件
|
||||
|
||||
use crate::ssr::pages::layout::PageLayout;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn AdminAccessPolicyPage(
|
||||
#[prop(optional)] workspace_name: Option<String>,
|
||||
#[prop(optional)] policy_path: Option<String>,
|
||||
) -> impl IntoView {
|
||||
let workspace_name = workspace_name
|
||||
.unwrap_or_else(|| "开发用户 的空间".to_string())
|
||||
.trim()
|
||||
.to_string();
|
||||
let policy_path = policy_path
|
||||
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string());
|
||||
view! {
|
||||
<PageLayout current_nav="admin" workspace_name={workspace_name.clone()} topbar_title={"目录授权".to_string()} show_admin_access_policy=true>
|
||||
<main class="mnote-admin-policy-page" data-testid="mnote-admin-access-policy-page">
|
||||
<header class="mnote-admin-policy-header">
|
||||
<h1>"目录授权"</h1>
|
||||
<p>"管理员可以查看、验证和管理本地目录授权,普通用户不会看到入口。"</p>
|
||||
</header>
|
||||
|
||||
<section class="mnote-admin-policy-summary">
|
||||
<div class="mnote-admin-policy-summary-item">
|
||||
<span class="mnote-admin-policy-summary-label">"工作区"</span>
|
||||
<strong data-testid="mnote-admin-workspace-name">{workspace_name.clone()}</strong>
|
||||
</div>
|
||||
<div class="mnote-admin-policy-summary-item">
|
||||
<span class="mnote-admin-policy-summary-label">"策略文件"</span>
|
||||
<code data-testid="mnote-admin-policy-path">{policy_path.clone()}</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mnote-admin-policy-panel">
|
||||
<header class="mnote-admin-policy-panel-header">
|
||||
<h2>"当前策略"</h2>
|
||||
<button type="button" data-testid="mnote-admin-policy-refresh" data-admin-action="refresh-policy">"刷新"</button>
|
||||
</header>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-policy-json">{""}</pre>
|
||||
<div class="mnote-admin-policy-note" data-testid="mnote-admin-policy-message"></div>
|
||||
</section>
|
||||
|
||||
<section class="mnote-admin-policy-grid">
|
||||
<form class="mnote-admin-policy-form" data-admin-form="validate-root">
|
||||
<header><h2>"验证目录"</h2></header>
|
||||
<label>
|
||||
<span>"rootUri"</span>
|
||||
<input data-testid="mnote-admin-root-uri" name="rootUri" type="text" placeholder="file:///mnt/Data1T/Mnote_data/users/..." />
|
||||
</label>
|
||||
<label>
|
||||
<span>"rootPath"</span>
|
||||
<input data-testid="mnote-admin-root-path" name="rootPath" type="text" placeholder="/mnt/Data1T/Mnote_data/..." />
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-validate-root-submit">"验证"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-validate-result"></pre>
|
||||
</form>
|
||||
|
||||
<form class="mnote-admin-policy-form" data-admin-form="create-grant">
|
||||
<header><h2>"新增授权"</h2></header>
|
||||
<label>
|
||||
<span>"grantId"</span>
|
||||
<input data-testid="mnote-admin-grant-id" name="grantId" type="text" placeholder="可留空自动生成" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"userId"</span>
|
||||
<input data-testid="mnote-admin-grant-user-id" name="userId" type="text" placeholder="user_123" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>"rootUri"</span>
|
||||
<input data-testid="mnote-admin-grant-root-uri" name="rootUri" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"rootPath"</span>
|
||||
<input data-testid="mnote-admin-grant-root-path" name="rootPath" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>"permission"</span>
|
||||
<select data-testid="mnote-admin-grant-permission" name="permission">
|
||||
<option value="read">"read"</option>
|
||||
<option value="write">"write"</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>"recursive"</span>
|
||||
<input data-testid="mnote-admin-grant-recursive" name="recursive" type="checkbox" checked=true />
|
||||
</label>
|
||||
<label>
|
||||
<span>"capabilities"</span>
|
||||
<input data-testid="mnote-admin-grant-capabilities" name="capabilities" type="text" placeholder="ai,share" />
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-create-grant-submit">"创建"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-create-result"></pre>
|
||||
</form>
|
||||
|
||||
<form class="mnote-admin-policy-form" data-admin-form="delete-grant">
|
||||
<header><h2>"删除授权"</h2></header>
|
||||
<label>
|
||||
<span>"grantId"</span>
|
||||
<input data-testid="mnote-admin-delete-grant-id" name="grantId" type="text" placeholder="grant_xxx" required />
|
||||
</label>
|
||||
<button type="submit" data-testid="mnote-admin-delete-grant-submit">"删除"</button>
|
||||
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-result"></pre>
|
||||
</form>
|
||||
</section>
|
||||
<script>{ADMIN_POLICY_SCRIPT}</script>
|
||||
</main>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
|
||||
const ADMIN_POLICY_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var root = document.querySelector('[data-testid="mnote-admin-access-policy-page"]');
|
||||
if (!root) return;
|
||||
var message = root.querySelector('[data-testid="mnote-admin-policy-message"]');
|
||||
var policyJson = root.querySelector('[data-testid="mnote-admin-policy-json"]');
|
||||
var validateResult = root.querySelector('[data-testid="mnote-admin-validate-result"]');
|
||||
var createResult = root.querySelector('[data-testid="mnote-admin-create-result"]');
|
||||
var deleteResult = root.querySelector('[data-testid="mnote-admin-delete-result"]');
|
||||
var refreshButton = root.querySelector('[data-admin-action="refresh-policy"]');
|
||||
|
||||
function setText(node, value) {
|
||||
if (!node) return;
|
||||
node.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function formValues(form) {
|
||||
var data = new FormData(form);
|
||||
var capabilities = String(data.get('capabilities') || '')
|
||||
.split(/[,\s]+/)
|
||||
.map(function (item) { return item.trim(); })
|
||||
.filter(Boolean);
|
||||
return {
|
||||
id: String(data.get('grantId') || '').trim(),
|
||||
userId: String(data.get('userId') || '').trim(),
|
||||
rootUri: String(data.get('rootUri') || '').trim(),
|
||||
rootPath: String(data.get('rootPath') || '').trim(),
|
||||
permission: String(data.get('permission') || 'read').trim(),
|
||||
recursive: data.get('recursive') === 'on' || data.get('recursive') === 'true',
|
||||
capabilities: capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(url, options) {
|
||||
var response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
var payload = await response.json().catch(function () { return {}; });
|
||||
if (!response.ok || payload.ok === false) {
|
||||
throw new Error((payload && payload.message) || ('请求失败: ' + response.status));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function refreshPolicy() {
|
||||
var payload = await requestJson('/api/admin/access-policy', { method: 'GET', headers: {} });
|
||||
setText(policyJson, payload);
|
||||
setText(message, '已刷新策略');
|
||||
}
|
||||
|
||||
refreshButton && refreshButton.addEventListener('click', function () {
|
||||
setText(message, '正在刷新策略...');
|
||||
refreshPolicy().catch(function (error) { setText(message, error.message || '刷新失败'); });
|
||||
});
|
||||
|
||||
root.querySelector('[data-admin-form="validate-root"]').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var values = formValues(event.currentTarget);
|
||||
setText(validateResult, '正在验证...');
|
||||
requestJson('/api/admin/access-policy/validate-root', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rootUri: values.rootUri, rootPath: values.rootPath }),
|
||||
}).then(function (payload) {
|
||||
setText(validateResult, payload);
|
||||
setText(message, '目录验证完成');
|
||||
}).catch(function (error) {
|
||||
setText(validateResult, { ok: false, error: error.message || '验证失败' });
|
||||
setText(message, error.message || '验证失败');
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector('[data-admin-form="create-grant"]').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var values = formValues(event.currentTarget);
|
||||
setText(createResult, '正在创建...');
|
||||
requestJson('/api/admin/access-policy/grants', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
}).then(function (payload) {
|
||||
setText(createResult, payload);
|
||||
setText(message, '授权已创建');
|
||||
return refreshPolicy();
|
||||
}).catch(function (error) {
|
||||
setText(createResult, { ok: false, error: error.message || '创建失败' });
|
||||
setText(message, error.message || '创建失败');
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector('[data-admin-form="delete-grant"]').addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var values = formValues(event.currentTarget);
|
||||
var grantId = values.id;
|
||||
setText(deleteResult, '正在删除...');
|
||||
requestJson('/api/admin/access-policy/grants/' + encodeURIComponent(grantId), {
|
||||
method: 'DELETE',
|
||||
headers: {},
|
||||
}).then(function (payload) {
|
||||
setText(deleteResult, payload);
|
||||
setText(message, '授权已删除');
|
||||
return refreshPolicy();
|
||||
}).catch(function (error) {
|
||||
setText(deleteResult, { ok: false, error: error.message || '删除失败' });
|
||||
setText(message, error.message || '删除失败');
|
||||
});
|
||||
});
|
||||
|
||||
refreshPolicy().catch(function (error) {
|
||||
setText(message, error.message || '加载策略失败');
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ssr::render_view;
|
||||
|
||||
#[test]
|
||||
fn admin_access_policy_page_renders_admin_controls() {
|
||||
let html = render_view(view! {
|
||||
<AdminAccessPolicyPage
|
||||
workspace_name={"我的空间".to_string()}
|
||||
policy_path={"/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string()}
|
||||
/>
|
||||
});
|
||||
assert!(html.contains("mnote-admin-access-policy-page"));
|
||||
assert!(html.contains("mnote-admin-policy-json"));
|
||||
assert!(html.contains("mnote-admin-validate-root-submit"));
|
||||
assert!(html.contains("mnote-admin-create-grant-submit"));
|
||||
assert!(html.contains("mnote-admin-delete-grant-submit"));
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,9 @@ pub fn DocumentPage(
|
||||
/// 右侧页面选项 JSON(可选)
|
||||
#[prop(optional)]
|
||||
secondary_page_options_json: String,
|
||||
/// 是否显示管理员授权入口
|
||||
#[prop(optional)]
|
||||
show_admin_access_policy: bool,
|
||||
) -> impl IntoView {
|
||||
let has_page_subtree = page_subtree_json
|
||||
.as_deref()
|
||||
@@ -285,7 +288,7 @@ pub fn DocumentPage(
|
||||
visible: secondary_visible,
|
||||
};
|
||||
view! {
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()} show_admin_access_policy={show_admin_access_policy}>
|
||||
<div
|
||||
class="document-workspace"
|
||||
data-testid="mnote-document-workspace"
|
||||
|
||||
@@ -24,6 +24,9 @@ pub fn HomePage(
|
||||
/// 当前选中的页面标题(可选)
|
||||
#[prop(optional)]
|
||||
active_page_title: Option<String>,
|
||||
/// 是否显示管理员授权入口
|
||||
#[prop(optional)]
|
||||
show_admin_access_policy: bool,
|
||||
) -> impl IntoView {
|
||||
let active_page_id = active_page_id.unwrap_or_default();
|
||||
let active_page_title = active_page_title
|
||||
@@ -40,7 +43,7 @@ pub fn HomePage(
|
||||
.map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}"))
|
||||
.unwrap_or_else(|| format!("/documents/{active_page_id}"));
|
||||
view! {
|
||||
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()}>
|
||||
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={active_page_title.clone()} show_admin_access_policy={show_admin_access_policy}>
|
||||
{move || if has_active_page {
|
||||
view! {
|
||||
<main class="document-shell document-shell--workspace-entry" data-root-active-page-id={active_page_id.clone()} data-editor-host="leptos_tiptap_island">
|
||||
@@ -61,7 +64,19 @@ pub fn HomePage(
|
||||
view! {
|
||||
<section class="mnote-workspace-empty-state" data-testid="mnote-workspace-empty-state">
|
||||
<h1>"暂无页面"</h1>
|
||||
<p>"当前工作区还没有可显示的页面。"</p>
|
||||
<p>"当前还没有可显示的本地工作区。"</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-empty-create-page"
|
||||
data-testid="mnote-create-default-local-workspace"
|
||||
data-mnote-action="create-local-workspace"
|
||||
>"创建我的空间"</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-empty-create-page"
|
||||
data-testid="mnote-open-local-folder-empty"
|
||||
data-mnote-action="open-local-folder"
|
||||
>"打开本地文件夹"</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-empty-create-page"
|
||||
|
||||
@@ -7,10 +7,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (window.__mnoteSidebarTreeRuntimeStarted) return;
|
||||
window.__mnoteSidebarTreeRuntimeStarted = true;
|
||||
|
||||
var PAGE_AI_SESSION_STORAGE_VERSION = 3;
|
||||
var PAGE_DRAG_MIME = 'application/x-mnote-page-tree-node';
|
||||
var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids';
|
||||
var MNOTE_SIDEBAR_TREE_MODE_KEY = 'mnote.sidebar.tree.mode';
|
||||
var MNOTE_RECENT_LOCAL_ROOTS_KEY = 'mnote.localFolder.recentRoots';
|
||||
var MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX = 'mnote.localFolder.recentRoots:';
|
||||
var MNOTE_LAST_CLOUD_WORKSPACE_KEY = 'mnote.workspace.lastCloudWorkspaceId';
|
||||
var MNOTE_GLOBAL_SHOW_HEADING_NUMBERS_KEY = 'mnote.global.showHeadingNumbers';
|
||||
var mnoteNavigationInFlight = '';
|
||||
@@ -39,7 +40,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
pageAiPage: 'chat',
|
||||
pageAiRunStatus: 'idle',
|
||||
pageAiCurrentRunId: '',
|
||||
pageAiAcpRuntime: '',
|
||||
pageAiAcpRuntime: 'reasonix',
|
||||
pageAiAcpRuntimes: [],
|
||||
pageAiQueueLength: 0,
|
||||
pageAiQueuedItems: [],
|
||||
@@ -618,7 +619,22 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function currentSourceKind() {
|
||||
return (new URLSearchParams(window.location.search).get('sourceKind') || 'convex_workspace').trim() || 'convex_workspace';
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var fromUrl = (params.get('sourceKind') || '').trim();
|
||||
if (fromUrl) return fromUrl;
|
||||
if (
|
||||
window.location.pathname === '/' &&
|
||||
!(params.get('workspaceId') || '').trim() &&
|
||||
!(params.get('rootUri') || '').trim() &&
|
||||
!(params.get('pageId') || '').trim()
|
||||
) {
|
||||
return 'local_folder';
|
||||
}
|
||||
return 'convex_workspace';
|
||||
}
|
||||
|
||||
function currentRootUri() {
|
||||
return (new URLSearchParams(window.location.search).get('rootUri') || '').trim();
|
||||
}
|
||||
|
||||
function rememberCloudWorkspaceId(workspaceId) {
|
||||
@@ -680,9 +696,22 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return payload;
|
||||
}
|
||||
|
||||
function currentActorStorageId() {
|
||||
var fromBody = document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-actor-id') || '').trim() : '';
|
||||
if (fromBody && fromBody !== 'anonymous') return fromBody;
|
||||
return '';
|
||||
}
|
||||
|
||||
function recentLocalRootsStorageKey() {
|
||||
var actorId = currentActorStorageId();
|
||||
return actorId ? MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX + encodeURIComponent(actorId) : '';
|
||||
}
|
||||
|
||||
function readRecentLocalRoots() {
|
||||
try {
|
||||
var raw = window.localStorage ? window.localStorage.getItem(MNOTE_RECENT_LOCAL_ROOTS_KEY) : '';
|
||||
var storageKey = recentLocalRootsStorageKey();
|
||||
if (!storageKey) return [];
|
||||
var raw = window.localStorage ? window.localStorage.getItem(storageKey) : '';
|
||||
var parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter(function(value) {
|
||||
return typeof value === 'string' && value.trim();
|
||||
@@ -695,9 +724,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
function rememberLocalRoot(rootUri) {
|
||||
try {
|
||||
if (!window.localStorage) return;
|
||||
var storageKey = recentLocalRootsStorageKey();
|
||||
if (!storageKey) return;
|
||||
var roots = readRecentLocalRoots().filter(function(value) { return value !== rootUri; });
|
||||
roots.unshift(rootUri);
|
||||
window.localStorage.setItem(MNOTE_RECENT_LOCAL_ROOTS_KEY, JSON.stringify(roots.slice(0, 10)));
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(roots.slice(0, 10)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -1036,6 +1067,47 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
openLocalFolderDialog('');
|
||||
}
|
||||
|
||||
async function createDefaultLocalWorkspace(trigger) {
|
||||
setCommandPending(trigger, true);
|
||||
try {
|
||||
var response = await fetch('/api/local-folder/workspaces/default', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) {
|
||||
throw new Error((payload && payload.message) || ('local_workspace_create_failed_' + response.status));
|
||||
}
|
||||
var workspace = payload && payload.workspace || {};
|
||||
var rootUri = String(workspace.rootUri || '').trim();
|
||||
if (!rootUri) throw new Error('local_workspace_create_missing_root_uri');
|
||||
openLocalFolderRoot(rootUri);
|
||||
} catch (error) {
|
||||
var status = document.querySelector('[data-testid="mnote-local-folder-status"]');
|
||||
if (status instanceof HTMLElement) {
|
||||
status.textContent = error && error.message ? error.message : String(error);
|
||||
} else {
|
||||
openLocalFolderDialog(error && error.message ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
setCommandPending(trigger, false);
|
||||
}
|
||||
}
|
||||
|
||||
function autoOpenRecentLocalRootOnHome() {
|
||||
if (window.location.pathname !== '/') return false;
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if ((params.get('sourceKind') || '').trim()) return false;
|
||||
if ((params.get('rootUri') || '').trim()) return false;
|
||||
if ((params.get('workspaceId') || '').trim()) return false;
|
||||
if ((params.get('pageId') || '').trim()) return false;
|
||||
var recent = readRecentLocalRoots();
|
||||
if (!recent.length) return false;
|
||||
openLocalFolderRoot(recent[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeWorkspaceSourceMenu() {
|
||||
var existing = document.querySelector('[data-testid="mnote-workspace-source-menu"]');
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
@@ -1109,6 +1181,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
trigger.closest('[data-testid="wolai-workspace-identity"]')?.appendChild(menu);
|
||||
}
|
||||
|
||||
autoOpenRecentLocalRootOnHome();
|
||||
|
||||
function setCommandPending(trigger, pending) {
|
||||
if (!(trigger instanceof HTMLElement)) return;
|
||||
trigger.setAttribute('data-pending', pending ? 'true' : 'false');
|
||||
@@ -1970,6 +2044,21 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return assetId.indexOf('mindmap_') === 0 || assetId.indexOf('mindmap-') === 0;
|
||||
}
|
||||
|
||||
function localFilePathFromAssetId(assetId) {
|
||||
var value = String(assetId || '').trim();
|
||||
return value.indexOf('local-file:') === 0 ? value.slice('local-file:'.length) : '';
|
||||
}
|
||||
|
||||
function buildLocalFileOpenUrl(relativePath, download) {
|
||||
var rootUri = currentRootUri();
|
||||
if (!rootUri || !relativePath) return '';
|
||||
var url = new URL('/api/local-folder/files/open', window.location.origin);
|
||||
url.searchParams.set('rootUri', rootUri);
|
||||
url.searchParams.set('path', relativePath);
|
||||
if (download) url.searchParams.set('download', 'true');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function readFileTreeObjectIdentity(row) {
|
||||
if (!row) return null;
|
||||
var raw = row.getAttribute('data-object-identity') || '';
|
||||
@@ -1999,6 +2088,12 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function openConvexAssetFromFileTree(detail) {
|
||||
var assetId = String(detail && detail.assetId || '').trim();
|
||||
if (!assetId) return;
|
||||
var localFilePath = localFilePathFromAssetId(assetId);
|
||||
if (localFilePath) {
|
||||
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
|
||||
if (localFileUrl) window.open(localFileUrl, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
var documentId = String(detail && detail.documentId || '').trim();
|
||||
if (isMindmapAssetDetail(detail) && documentId) {
|
||||
document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-object-shell');
|
||||
@@ -2133,13 +2228,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function uploadedAssetUrl(asset) {
|
||||
return String(asset && (asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
|
||||
return String(asset && (asset.sourcePath || asset.file_url || asset.signedUrl || asset.signed_url || asset.thumbnail_url) || '').trim();
|
||||
}
|
||||
|
||||
function uploadedAssetType(asset) {
|
||||
return String(asset && (asset.asset_type || asset.assetType || asset.mime_type || '') || '').trim();
|
||||
}
|
||||
|
||||
function isLocalUploadedAsset(asset) {
|
||||
var id = String(asset && asset.id || '').trim();
|
||||
return String(asset && asset.sourceKind || '').trim() === 'local_folder' || id.indexOf('local:asset:') === 0;
|
||||
}
|
||||
|
||||
function uploadedAssetExtension(asset) {
|
||||
var match = uploadedAssetTitle(asset).toLowerCase().match(/\.([a-z0-9]+)$/);
|
||||
return match ? match[1] : '';
|
||||
@@ -2224,6 +2324,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function buildOnlyOfficeAssetOpenUrl(asset, userId) {
|
||||
if (isLocalUploadedAsset(asset)) return '';
|
||||
var title = uploadedAssetTitle(asset);
|
||||
var fileType = inferOnlyOfficeFileType(title, asset && asset.mime_type);
|
||||
if (!fileType) return '';
|
||||
@@ -2591,8 +2692,9 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (type === 'image' && url) {
|
||||
return editor.chain().focus().setImage({ src: url, alt: title, title: title }).run() === true;
|
||||
}
|
||||
var isLocalAsset = isLocalUploadedAsset(asset);
|
||||
var userId = '';
|
||||
var onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
||||
var onlyOfficeUrl = isLocalAsset ? '' : buildOnlyOfficeAssetOpenUrl(asset, userId);
|
||||
if (onlyOfficeUrl && assetId) {
|
||||
userId = await fetchCurrentOnlyOfficeUserId();
|
||||
onlyOfficeUrl = buildOnlyOfficeAssetOpenUrl(asset, userId);
|
||||
@@ -2647,6 +2749,35 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
async function uploadFileToMediaAsset(file, plan, options) {
|
||||
if (currentSourceKind() === 'local_folder') {
|
||||
var rootUri = (new URLSearchParams(window.location.search).get('rootUri') || '').trim();
|
||||
var documentId = String(plan && plan.targetDocumentId || currentDocumentId() || '').trim();
|
||||
if (!rootUri || !documentId) {
|
||||
throw new Error('本地 Markdown 上传缺少 rootUri 或 documentId');
|
||||
}
|
||||
var localForm = new FormData();
|
||||
localForm.append('file', file);
|
||||
localForm.append('rootUri', rootUri);
|
||||
localForm.append('documentId', documentId);
|
||||
localForm.append('kind', file && String(file.type || '').indexOf('image/') === 0 ? 'image' : 'attachment');
|
||||
var localResponse = await fetch('/api/local-folder/assets/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: localForm
|
||||
});
|
||||
var localPayload = await localResponse.json().catch(function() { return null; });
|
||||
if (!localResponse.ok || !localPayload || !localPayload.asset) {
|
||||
throw new Error(localPayload && localPayload.error ? localPayload.error : '上传失败');
|
||||
}
|
||||
if (options && options.insertIntoEditor) {
|
||||
await insertUploadedAssetIntoEditor(localPayload.asset);
|
||||
}
|
||||
void refreshLocalFolderSidebarSnapshot();
|
||||
window.dispatchEvent(new CustomEvent('wolai:local-assets-changed', {
|
||||
detail: { docId: documentId, asset: localPayload.asset, assetIds: [localPayload.asset.id] }
|
||||
}));
|
||||
return localPayload.asset;
|
||||
}
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('workspaceId', plan.workspaceId);
|
||||
@@ -3951,6 +4082,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
params.set('workspaceId', resolveWorkspaceId(document.body));
|
||||
params.set('documentId', currentDocumentId());
|
||||
params.set('profile', pageAiRunProfile());
|
||||
params.set('sourceKind', currentSourceKind());
|
||||
if (currentRootUri()) params.set('rootUri', currentRootUri());
|
||||
Object.keys(extra || {}).forEach(function(key) {
|
||||
var value = extra[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
@@ -3966,7 +4099,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
|
||||
title: title || '新会话',
|
||||
profile: pageAiCurrentProfile(),
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
source: 'local',
|
||||
@@ -4093,14 +4226,20 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return (Array.isArray(sessions) ? sessions : [])
|
||||
.slice(0, 20)
|
||||
.map(function(session) {
|
||||
var sessionStorage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
|
||||
var persistence = String(session && session.persistence || '').trim();
|
||||
return {
|
||||
id: String(session && session.id || '').trim() || pageAiNewSession().id,
|
||||
title: String(session && session.title || '').trim() || '新会话',
|
||||
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
|
||||
acpRuntime: String(session && session.acpRuntime || '').trim(),
|
||||
acpRuntime: String(session && session.acpRuntime || 'reasonix').trim(),
|
||||
createdAt: pageAiTimestamp(session && session.createdAt),
|
||||
updatedAt: pageAiTimestamp(session && session.updatedAt),
|
||||
source: String(session && session.source || 'local').trim() || 'local',
|
||||
persistence: persistence,
|
||||
sessionStorage: sessionStorage,
|
||||
permissionLevel: String(session && (session.permissionLevel || session.permission_level) || '').trim(),
|
||||
shareId: String(session && (session.shareId || session.share_id) || '').trim(),
|
||||
runId: String(session && (session.runId || session.run_id) || '').trim(),
|
||||
status: String(session && session.status || '').trim(),
|
||||
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
|
||||
@@ -4120,6 +4259,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (!sessionId) return null;
|
||||
var title = String(row.title || payload.title || payload.message || '').trim();
|
||||
if (title.length > 28) title = title.slice(0, 28) + '…';
|
||||
var persistence = String(row.persistence || payload.persistence || '').trim();
|
||||
var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim();
|
||||
if (!sessionStorage && persistence === 'convex_acp_runtime_store') sessionStorage = 'cloud';
|
||||
if (!sessionStorage && persistence === 'local_ai_session_jsonl') sessionStorage = String(row.shareId || payload.shareId || '').trim() ? 'local_shared' : 'local_private';
|
||||
return {
|
||||
id: sessionId,
|
||||
title: title || '当前页问答',
|
||||
@@ -4127,7 +4270,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(),
|
||||
createdAt: pageAiTimestamp(row.createdAt || row.created_at),
|
||||
updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at),
|
||||
source: 'acp',
|
||||
source: sessionStorage === 'local_private' || sessionStorage === 'local_shared' ? 'local' : 'acp',
|
||||
persistence: persistence,
|
||||
sessionStorage: sessionStorage,
|
||||
permissionLevel: String(row.permissionLevel || row.permission_level || payload.permissionLevel || '').trim(),
|
||||
shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(),
|
||||
runId: String(row.runId || row.run_id || '').trim(),
|
||||
status: String(row.status || (row.runtime && row.runtime.status) || '').trim(),
|
||||
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
|
||||
@@ -4150,6 +4297,16 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
|
||||
}
|
||||
|
||||
function pageAiSessionStorageLabel(session) {
|
||||
var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
|
||||
var persistence = String(session && session.persistence || '').trim();
|
||||
if (storage === 'local_shared') return '共享会话';
|
||||
if (storage === 'local_private') return '本地私有';
|
||||
if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话';
|
||||
if (persistence === 'local_ai_session_jsonl') return '本地私有';
|
||||
return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有';
|
||||
}
|
||||
|
||||
function pageAiLoadSessions() {
|
||||
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
|
||||
try {
|
||||
@@ -4159,14 +4316,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
|
||||
var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim();
|
||||
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions);
|
||||
if (activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
|
||||
var storageVersion = Number(parsed && parsed.version || 0);
|
||||
if (storageVersion >= PAGE_AI_SESSION_STORAGE_VERSION && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
|
||||
if (activeProfile) pageAiSetActiveProfile(activeProfile);
|
||||
if (sessions.length) {
|
||||
pageUiState.pageAiSessions = sessions;
|
||||
var activeSession = sessions.find(function(session) { return session.id === activeId; }) || sessions[0];
|
||||
pageUiState.pageAiActiveSessionId = activeSession.id;
|
||||
if (activeSession.profile) pageAiSetActiveProfile(activeSession.profile);
|
||||
if (activeSession.acpRuntime) pageUiState.pageAiAcpRuntime = activeSession.acpRuntime;
|
||||
if (!pageUiState.pageAiAcpRuntime && activeSession.acpRuntime) pageUiState.pageAiAcpRuntime = activeSession.acpRuntime;
|
||||
pageUiState.pageAiMessages = Array.isArray(activeSession.messages) ? activeSession.messages.slice() : [];
|
||||
return;
|
||||
}
|
||||
@@ -4201,7 +4359,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var active = pageAiCurrentSession();
|
||||
if (active) {
|
||||
if (active.profile) pageAiSetActiveProfile(active.profile);
|
||||
if (active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
|
||||
if (!pageUiState.pageAiAcpRuntime && active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
|
||||
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages;
|
||||
}
|
||||
pageUiState.pageAiSessionError = '';
|
||||
@@ -4319,10 +4477,10 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
try {
|
||||
pageAiSyncCurrentSessionMessages();
|
||||
window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
|
||||
version: 2,
|
||||
version: PAGE_AI_SESSION_STORAGE_VERSION,
|
||||
activeSessionId: pageUiState.pageAiActiveSessionId,
|
||||
activeProfileName: pageAiCurrentProfile(),
|
||||
activeAcpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
|
||||
}));
|
||||
} catch (_) {}
|
||||
@@ -4338,6 +4496,8 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
traceId: 'page-ai-' + Date.now().toString(36),
|
||||
profile: pageAiCurrentProfile(),
|
||||
title: current && current.title ? current.title : '当前页问答'
|
||||
@@ -4351,7 +4511,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
id: String(payload.sessionId || '').trim(),
|
||||
title: String(payload.title || '当前页问答'),
|
||||
profile: String(payload.profile || pageAiCurrentProfile()).trim() || 'default',
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
persistence: String(payload.persistence || '').trim(),
|
||||
sessionStorage: String(payload.sessionStorage || '').trim(),
|
||||
permissionLevel: String(payload.permissionLevel || '').trim(),
|
||||
shareId: String(payload.shareId || '').trim(),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
messages: pageUiState.pageAiMessages.slice()
|
||||
@@ -4411,7 +4575,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (!session) return;
|
||||
session.messages = Array.isArray(pageUiState.pageAiMessages) ? pageUiState.pageAiMessages.slice(-300) : [];
|
||||
session.profile = pageAiCurrentProfile();
|
||||
session.acpRuntime = pageUiState.pageAiAcpRuntime || '';
|
||||
session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix';
|
||||
session.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
@@ -4518,6 +4682,36 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function pageAiDefaultAcpRuntimes() {
|
||||
return [
|
||||
{
|
||||
name: 'reasonix',
|
||||
title: 'ACP · Reasonix',
|
||||
description: '通过 ACP 协议直连 Reasonix(DeepSeek 缓存优先)',
|
||||
model: 'deepseek-chat',
|
||||
preset: 'auto'
|
||||
},
|
||||
{
|
||||
name: 'hermes',
|
||||
title: 'ACP · Hermes',
|
||||
description: '通过 ACP 协议直连 Hermes agent runtime'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function pageAiNormalizeAcpRuntimes(runtimes) {
|
||||
var byName = {};
|
||||
pageAiDefaultAcpRuntimes().forEach(function(runtime) {
|
||||
byName[runtime.name] = Object.assign({}, runtime);
|
||||
});
|
||||
pageAiNormalizeArray(runtimes).forEach(function(runtime) {
|
||||
var name = String(runtime && runtime.name || '').trim();
|
||||
if (name !== 'reasonix' && name !== 'hermes') return;
|
||||
byName[name] = Object.assign({}, byName[name] || {}, runtime, { name: name });
|
||||
});
|
||||
return ['reasonix', 'hermes'].map(function(name) { return byName[name]; }).filter(Boolean);
|
||||
}
|
||||
|
||||
function pageAiUnwrapUpstream(payload) {
|
||||
if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream;
|
||||
return payload || null;
|
||||
@@ -4540,7 +4734,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function pageAiRunProfile() {
|
||||
return String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix' ? 'reasonix' : pageAiCurrentProfile();
|
||||
return String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix' ? 'reasonix' : pageAiCurrentProfile();
|
||||
}
|
||||
|
||||
function pageAiMnoteToolModel() {
|
||||
@@ -4705,6 +4899,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return String(name || '').trim().replace(/_/g, '.');
|
||||
}
|
||||
|
||||
function pageAiFormatChangedFiles(files) {
|
||||
return pageAiNormalizeArray(files).map(function(file) {
|
||||
var path = String(file && file.path || '').trim();
|
||||
var changeType = String(file && file.changeType || file.change_type || 'modified').trim();
|
||||
var summary = String(file && file.summary || '').trim();
|
||||
return [changeType, path, summary].filter(Boolean).join(' · ');
|
||||
}).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function pageAiToolEventDeepFindString(value, keys, depth) {
|
||||
if (!value || typeof value !== 'object' || depth > 5) return '';
|
||||
for (var index = 0; index < keys.length; index += 1) {
|
||||
@@ -5005,7 +5208,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
body: JSON.stringify({
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
profile: pageAiRunProfile(),
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
reason: 'page_ai_user_stop'
|
||||
})
|
||||
});
|
||||
@@ -5063,7 +5266,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var profiles = pageAiNormalizeProfiles(payload);
|
||||
pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'mnoteai', active: true }];
|
||||
var acpRuntimes = Array.isArray(payload.acpRuntimes) ? payload.acpRuntimes : [];
|
||||
pageUiState.pageAiAcpRuntimes = acpRuntimes;
|
||||
pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(acpRuntimes);
|
||||
var current = pageAiCurrentProfile();
|
||||
var hasCurrent = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === current; });
|
||||
var hasMnoteAi = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === 'mnoteai'; });
|
||||
@@ -5074,6 +5277,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
} catch (error) {
|
||||
pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error);
|
||||
if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'mnoteai', active: true }];
|
||||
pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(pageUiState.pageAiAcpRuntimes);
|
||||
pageAiSetActiveProfile(pageAiCurrentProfile());
|
||||
}
|
||||
renderPageAiProviderButtons();
|
||||
@@ -5156,7 +5360,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
async function pageAiLoadSkills() {
|
||||
try {
|
||||
var runtime = String(pageUiState.pageAiAcpRuntime || '').trim();
|
||||
var runtime = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim();
|
||||
var params = runtime === 'reasonix'
|
||||
? 'runtime=reasonix'
|
||||
: 'profile=' + encodeURIComponent(pageAiCurrentProfile());
|
||||
@@ -5178,7 +5382,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
async function pageAiToggleSkill(skillName, enabled) {
|
||||
var name = String(skillName || '').trim();
|
||||
if (!name) return;
|
||||
if (String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix') return;
|
||||
if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return;
|
||||
var previous = null;
|
||||
pageAiSkillListEntries().forEach(function(skill) {
|
||||
if (skill.name === name && previous == null) previous = skill.enabled !== false;
|
||||
@@ -5275,16 +5479,15 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var isAcp = pageUiState.pageAiAcpRuntime !== '';
|
||||
var activeProfile = pageAiRunProfile();
|
||||
drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat');
|
||||
drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || '');
|
||||
drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || 'reasonix');
|
||||
// Populate ACP runtime dropdown
|
||||
var acpSelect = drawer.querySelector('[data-page-ai-acp-runtime]');
|
||||
if (acpSelect instanceof HTMLSelectElement) {
|
||||
var runtimes = pageUiState.pageAiAcpRuntimes.length ? pageUiState.pageAiAcpRuntimes : [];
|
||||
acpSelect.innerHTML = '<option value="">默认 (Hermes HTTP)</option>' +
|
||||
runtimes.map(function(rt) {
|
||||
var runtimes = pageUiState.pageAiAcpRuntimes.length ? pageUiState.pageAiAcpRuntimes : pageAiNormalizeAcpRuntimes([]);
|
||||
acpSelect.innerHTML = runtimes.map(function(rt) {
|
||||
return '<option value="' + escapeHtml(rt.name || '') + '"' + ((rt.name || '') === pageUiState.pageAiAcpRuntime ? ' selected' : '') + '>' + escapeHtml(rt.title || rt.name) + '</option>';
|
||||
}).join('');
|
||||
acpSelect.value = pageUiState.pageAiAcpRuntime || '';
|
||||
acpSelect.value = pageUiState.pageAiAcpRuntime || 'reasonix';
|
||||
}
|
||||
// Show/hide Hermes-specific profile select
|
||||
var profileLabel = drawer.querySelector('[data-page-ai-hermes-profile]');
|
||||
@@ -5366,7 +5569,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var session = pageAiCurrentSession();
|
||||
var usageText = session && session.usage ? pageAiUsageSummary(session.usage) : '';
|
||||
sessionNode.textContent = session && session.id
|
||||
? [String(session.title || '当前页问答'), usageText].filter(Boolean).join(' · ')
|
||||
? [String(session.title || '当前页问答'), pageAiSessionStorageLabel(session), usageText].filter(Boolean).join(' · ')
|
||||
: '等待 Hermes session';
|
||||
}
|
||||
var modelNode = drawer.querySelector('[data-page-ai-model-status]');
|
||||
@@ -5438,7 +5641,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
if (skillList instanceof HTMLElement) {
|
||||
var skills = pageAiFilteredSkillEntries();
|
||||
if (!skills.length) {
|
||||
var emptyText = String(pageUiState.pageAiAcpRuntime || '').trim() === 'reasonix'
|
||||
var emptyText = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix'
|
||||
? '没有匹配的 Reasonix skill。'
|
||||
: '没有匹配的 Hermes skill。';
|
||||
skillList.innerHTML = '<div class="wolai-page-ai-empty">' + escapeHtml(emptyText) + '</div>';
|
||||
@@ -5447,7 +5650,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : '');
|
||||
var description = String(skill.description || '').trim();
|
||||
var hasDescription = description && description !== '---' && description !== '无描述';
|
||||
var canToggle = skill.toggleable !== false && String(pageUiState.pageAiAcpRuntime || '').trim() !== 'reasonix';
|
||||
var canToggle = skill.toggleable !== false && String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() !== 'reasonix';
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-skill-row">' +
|
||||
'<div class="wolai-page-ai-skill-copy">' +
|
||||
@@ -5618,9 +5821,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
'<div class="wolai-page-ai-settings-grid">' +
|
||||
'<label class="wolai-page-ai-profile-select">' +
|
||||
'<span>ACP</span>' +
|
||||
'<select data-page-ai-acp-runtime>' +
|
||||
'<option value="">默认 (Hermes HTTP)</option>' +
|
||||
'</select>' +
|
||||
'<select data-page-ai-acp-runtime></select>' +
|
||||
'</label>' +
|
||||
'<label class="wolai-page-ai-profile-select" data-page-ai-hermes-profile>' +
|
||||
'<span>agent / profile</span>' +
|
||||
@@ -5756,7 +5957,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
: (session.snippet || session.preview || '暂无消息');
|
||||
var active = session.id === pageUiState.pageAiActiveSessionId;
|
||||
var usage = pageAiUsageSummary(session.usage);
|
||||
var meta = [session.source === 'acp' ? 'Convex' : '本地', session.status, usage].filter(Boolean).join(' · ');
|
||||
var meta = [pageAiSessionStorageLabel(session), session.permissionLevel, session.shareId, session.status, usage].filter(Boolean).join(' · ');
|
||||
return '' +
|
||||
'<div class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + '" data-page-ai-session-row="' + escapeHtml(session.id) + '">' +
|
||||
'<button type="button" class="wolai-page-ai-message-text" data-page-ai-session="' + escapeHtml(session.id) + '">' +
|
||||
@@ -5784,6 +5985,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var detailRows = [
|
||||
item.argsSummary ? '<div class="wolai-page-ai-tool-meta">参数 ' + escapeHtml(item.argsSummary) + '</div>' : '',
|
||||
item.resultSummary ? '<div class="wolai-page-ai-tool-meta">结果 ' + escapeHtml(item.resultSummary) + '</div>' : '',
|
||||
item.changedFiles && item.changedFiles.length ? '<pre class="wolai-page-ai-tool-meta wolai-page-ai-changed-files">' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '</pre>' : '',
|
||||
(item.traceId || item.auditId) ? '<div class="wolai-page-ai-tool-meta">' + escapeHtml([item.traceId, item.auditId].filter(Boolean).join(' · ')) + '</div>' : ''
|
||||
].filter(Boolean).join('');
|
||||
return '' +
|
||||
@@ -5975,6 +6177,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
async function pageAiTryBlockEditWorkflow(prompt, scopedContext) {
|
||||
if (currentSourceKind() === 'local_folder') return false;
|
||||
if (!pageAiLooksLikeBlockEdit(prompt)) return false;
|
||||
var runId = 'page-ai-fast-' + Date.now().toString(36);
|
||||
var traceId = 'page-ai-fast-' + Date.now().toString(36);
|
||||
@@ -5985,11 +6188,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
runId: runId,
|
||||
profile: pageAiCurrentProfile(),
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
runId: runId,
|
||||
profile: pageAiCurrentProfile(),
|
||||
model: pageAiMnoteToolModel(),
|
||||
message: prompt,
|
||||
pageContext: scopedContext.pageContext,
|
||||
@@ -6083,9 +6288,11 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
body: JSON.stringify({
|
||||
workspaceId: resolveWorkspaceId(document.body),
|
||||
documentId: currentDocumentId(),
|
||||
sourceKind: currentSourceKind(),
|
||||
rootUri: currentRootUri(),
|
||||
sessionId: pageUiState.pageAiActiveSessionId,
|
||||
profile: pageAiRunProfile(),
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || '',
|
||||
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
|
||||
contextScope: pageUiState.pageAiContextScope,
|
||||
message: prompt,
|
||||
model: pageAiMnoteToolModel(),
|
||||
@@ -6184,6 +6391,23 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
var completedSession = pageAiCurrentSession();
|
||||
if (completedSession) completedSession.usage = completed.usage;
|
||||
}
|
||||
var agentAudit = completed && completed.agentAudit && typeof completed.agentAudit === 'object' ? completed.agentAudit : null;
|
||||
var changedFiles = pageAiNormalizeArray(agentAudit && agentAudit.changedFiles);
|
||||
if (changedFiles.length) {
|
||||
pageUiState.pageAiMessages.push({
|
||||
role: 'tool',
|
||||
content: 'agent.changed_files',
|
||||
toolCallId: runId + ':agent.changed_files',
|
||||
toolName: 'agent.changed_files',
|
||||
toolKind: 'audit',
|
||||
status: 'completed',
|
||||
argsSummary: String(agentAudit.rootUri || ''),
|
||||
resultSummary: String(agentAudit.diffSummary || changedFiles.length + ' changed file(s)'),
|
||||
changedFiles: changedFiles,
|
||||
traceId: runTraceId,
|
||||
auditId: String(agentAudit.eventId || '')
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
pageAiSetRunStatus('completed', runId);
|
||||
}
|
||||
@@ -7014,6 +7238,13 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
return;
|
||||
}
|
||||
|
||||
var createLocalWorkspaceTrigger = closestAction(e.target, '[data-mnote-action="create-local-workspace"]');
|
||||
if (createLocalWorkspaceTrigger) {
|
||||
e.preventDefault();
|
||||
void createDefaultLocalWorkspace(createLocalWorkspaceTrigger);
|
||||
return;
|
||||
}
|
||||
|
||||
var createTrigger = closestAction(e.target, '[data-mnote-action="create-page"]');
|
||||
if (createTrigger) {
|
||||
e.preventDefault();
|
||||
@@ -7231,7 +7462,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
document.addEventListener('change', function(event) {
|
||||
var pageAiAcpRuntimeSelect = closestAction(event.target, '[data-page-ai-acp-runtime]');
|
||||
if (pageAiAcpRuntimeSelect instanceof HTMLSelectElement) {
|
||||
var next = String(pageAiAcpRuntimeSelect.value || '').trim();
|
||||
var next = String(pageAiAcpRuntimeSelect.value || 'reasonix').trim() || 'reasonix';
|
||||
pageUiState.pageAiAcpRuntime = next;
|
||||
pageUiState.pageAiSkills = { categories: [], archived: [] };
|
||||
pageUiState.pageAiSkillError = '';
|
||||
@@ -7829,6 +8060,9 @@ pub fn PageLayout(
|
||||
/// 顶栏当前页面标题(可选)
|
||||
#[prop(optional)]
|
||||
topbar_title: Option<String>,
|
||||
/// 是否显示管理员授权入口
|
||||
#[prop(optional)]
|
||||
show_admin_access_policy: bool,
|
||||
) -> impl IntoView {
|
||||
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
|
||||
|
||||
@@ -7894,6 +8128,21 @@ pub fn PageLayout(
|
||||
<a href="/help" title="帮助" aria-label="帮助"><span class="material-symbols-outlined nav-icon" data-icon="help" aria-hidden="true"></span></a>
|
||||
<a href="/files" title="文件" aria-label="文件"><span class="material-symbols-outlined nav-icon" data-icon="inventory_2" aria-hidden="true"></span></a>
|
||||
<button type="button" title="打开本地文件夹" aria-label="打开本地文件夹" data-mnote-action="open-local-folder"><span class="material-symbols-outlined nav-icon" data-icon="folder_open" aria-hidden="true"></span></button>
|
||||
{if show_admin_access_policy {
|
||||
view! {
|
||||
<a
|
||||
href="/admin/access-policy"
|
||||
class:active={current_nav == "admin"}
|
||||
title="目录授权"
|
||||
aria-label="目录授权"
|
||||
data-testid="mnote-admin-access-policy-entry"
|
||||
>
|
||||
<span class="material-symbols-outlined nav-icon" data-icon="admin_panel_settings" aria-hidden="true"></span>
|
||||
</a>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
}}
|
||||
<a href="/more" title="更多" aria-label="更多"><span class="material-symbols-outlined nav-icon" data-icon="more_horiz" aria-hidden="true"></span></a>
|
||||
</nav>
|
||||
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
||||
@@ -7987,6 +8236,9 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("objectIdentity: objectIdentity"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("workspaceId: resolveWorkspaceId(fileRow)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/media/sign?assetId="));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function localFilePathFromAssetId"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/files/open"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("local-file:"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("fetchCurrentOnlyOfficeUserId"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("buildOnlyOfficeOpenUrl"));
|
||||
@@ -7998,8 +8250,11 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_JS.contains("tree-rename-input"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sidebarFileTreeClipboard"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("pasteSidebarFileTreeClipboard"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function recentLocalRootsStorageKey"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("data-mnote-actor-id"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("requestOpenLocalFolder"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("autoOpenRecentLocalRootOnHome"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sourceKind', 'local_folder"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("switchToCloudWorkspace"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("mnote-switch-cloud-workspace"));
|
||||
@@ -8018,6 +8273,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_fast_path_is_not_local_first_main_path() {
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder') return false;"),
|
||||
"local-first 页面 AI 不应继续加厚 page-ai fast-path;本地编辑应走受控文件工具"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_local_source_passes_file_reference_fields_to_agent_run() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function currentRootUri()"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sourceKind: currentSourceKind()"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("rootUri: currentRootUri()"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("pageContext: scopedContext.pageContext"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder') return false;"),
|
||||
"local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_uses_backend_acp_session_runtime_store() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiLoadBackendSessions"));
|
||||
@@ -8043,6 +8318,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_session_ui_labels_local_shared_and_cloud_storage() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiSessionStorageLabel"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("local_private"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("local_shared"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("convex_acp_runtime_store"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("本地私有"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("共享会话"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("云端会话"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("sessionStorage:"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("permissionLevel:"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("shareId:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 3"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("pageAiAcpRuntime: 'reasonix'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("function pageAiNormalizeAcpRuntimes"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("return ['reasonix', 'hermes']"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'"));
|
||||
assert!(!SIDEBAR_TREE_JS.contains("默认 (Hermes HTTP)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_does_not_use_retired_query_preferred_snapshot_selector() {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
|
||||
@@ -8081,6 +8383,18 @@ mod tests {
|
||||
assert!(!SIDEBAR_TREE_JS.contains("window.location.reload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_upload_runtime_routes_local_markdown_assets_to_local_folder() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("currentSourceKind() === 'local_folder'"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("/api/local-folder/assets/upload"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("localForm.append('rootUri', rootUri)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("localForm.append('documentId', documentId)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("isLocalUploadedAsset(asset)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("if (isLocalUploadedAsset(asset)) return '';"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("wolai:local-assets-changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_tree_runtime_contains_dev_hot_reload_client() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("installMnoteDevHotReload"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! SSR 页面组件
|
||||
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod document;
|
||||
pub mod home;
|
||||
|
||||
@@ -1876,6 +1876,147 @@ body {
|
||||
background: var(--atelier-document);
|
||||
}
|
||||
|
||||
.mnote-admin-policy-page {
|
||||
width: min(1080px, calc(100vw - 64px));
|
||||
margin: 40px auto 64px;
|
||||
color: var(--atelier-text);
|
||||
}
|
||||
|
||||
.mnote-admin-policy-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-header p,
|
||||
.mnote-admin-policy-note {
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-summary,
|
||||
.mnote-admin-policy-panel,
|
||||
.mnote-admin-policy-form {
|
||||
border: 1px solid var(--wolai-border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(280px, 2fr);
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-summary-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-summary-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-summary code,
|
||||
.mnote-admin-policy-json {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-panel {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-panel h2,
|
||||
.mnote-admin-policy-form h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-form input,
|
||||
.mnote-admin-policy-form select {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--wolai-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
color: var(--wolai-text-primary);
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-form button,
|
||||
.mnote-admin-policy-panel button {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--wolai-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: var(--wolai-text-primary);
|
||||
background: var(--wolai-bg-sidebar);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-admin-policy-form button:hover,
|
||||
.mnote-admin-policy-panel button:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.mnote-admin-policy-json {
|
||||
min-height: 48px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: var(--wolai-bg-sidebar);
|
||||
padding: 10px;
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.mnote-admin-policy-summary,
|
||||
.mnote-admin-policy-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mnote-trash-workbench {
|
||||
width: min(860px, calc(100vw - 64px));
|
||||
margin: 52px auto;
|
||||
@@ -2330,6 +2471,99 @@ body {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-editor-conflict-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: -28px 0 28px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #E2B86C;
|
||||
border-radius: 8px;
|
||||
background: #FFF8E6;
|
||||
color: var(--wolai-text-primary);
|
||||
}
|
||||
|
||||
.mnote-editor-conflict-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.mnote-editor-conflict-panel p {
|
||||
margin: 0;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.mnote-conflict-meta {
|
||||
margin: 0;
|
||||
color: var(--wolai-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-conflict-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-conflict-actions button {
|
||||
min-height: 30px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.16);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
background: #fff;
|
||||
color: var(--wolai-text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mnote-conflict-actions button:hover {
|
||||
background: var(--wolai-bg-hover);
|
||||
}
|
||||
|
||||
.mnote-conflict-diff-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mnote-conflict-diff-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mnote-conflict-diff-panel h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--wolai-text-secondary);
|
||||
}
|
||||
|
||||
.mnote-conflict-diff-panel pre,
|
||||
.mnote-conflict-diff-status {
|
||||
min-height: 96px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 10px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mnote-conflict-diff-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
#mnote-editor-island {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -3195,6 +3429,12 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-changed-files {
|
||||
overflow: auto;
|
||||
text-overflow: clip;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-provider-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -129,6 +129,8 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
|
||||
Ok(format!("Convex {admin_key}:{identity_encoded}"))
|
||||
}
|
||||
|
||||
// Convex 授权在 local-first 下只作为控制面身份委托;
|
||||
// 本地 workspace 的正文、附件和 AI 会话全文不依赖这里获得写入真相。
|
||||
struct ActingIdentityUser {
|
||||
user_id: String,
|
||||
session_suffix: &'static str,
|
||||
@@ -383,6 +385,8 @@ pub async fn execute_sidebar_dataset_query(
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeQueryExecutionPlan,
|
||||
) -> Result<Value, WebError> {
|
||||
// sidebar:datasetList 只保留为 cloud source / compat projection source;
|
||||
// 默认 local-first 首屏应优先读取 LocalFS projection。
|
||||
execute_convex_query_plan(config, context, plan).await
|
||||
}
|
||||
|
||||
@@ -410,6 +414,8 @@ pub async fn execute_convex_query_by_name(
|
||||
}
|
||||
|
||||
fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
// 这里是 Rust command plan 到 legacy Convex validator 的兼容适配层。
|
||||
// documents:* 属于 compat / cloud source / sync replica,mediaAssets:* 属于 cloud resource replica。
|
||||
let mut args = plan.args_json.clone();
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
@@ -448,13 +454,11 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
|
||||
plan.command_name.as_str(),
|
||||
"documents.save" | "page.body.save"
|
||||
) {
|
||||
if let Value::Object(map) = &mut args {
|
||||
// 当前自托管 Convex 的 documents:updateContent 仍是 legacy validator。
|
||||
// Rust plan 保留正式事件契约,但发送给 legacy mutation 时只传它实际接受的字段。
|
||||
map.remove("editorDocument");
|
||||
map.remove("tiptapDocument");
|
||||
}
|
||||
// 当前 Convex documents:updateContent 仍是 legacy content substrate:
|
||||
// 真实可见保存必须只发送 validator 接受的字段;原生编辑器快照留在
|
||||
// Rust plan / artifact 侧,待 Convex schema 正式迁移后再进入持久层。
|
||||
strip_tree_artifact_fields(&mut args);
|
||||
strip_editor_snapshot_fields(&mut args);
|
||||
}
|
||||
if matches!(
|
||||
plan.command_name.as_str(),
|
||||
@@ -552,6 +556,15 @@ fn strip_tree_artifact_fields(args: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_editor_snapshot_fields(args: &mut Value) {
|
||||
if let Value::Object(map) = args {
|
||||
map.remove("editorDocument");
|
||||
map.remove("tiptapDocument");
|
||||
map.remove("blockDocument");
|
||||
map.remove("blockProjectionVersion");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_convex_command_plan(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -850,6 +863,7 @@ pub async fn persist_runtime_command_artifacts(
|
||||
context: &RequestContext,
|
||||
artifacts: &RuntimeCommandArtifactPlan,
|
||||
) -> Result<(), WebError> {
|
||||
// bridgeLogs 只作为控制面审计与事件副本,不承载 local-first 页面正文真相。
|
||||
execute_convex_mutation_by_name(
|
||||
config,
|
||||
context,
|
||||
@@ -958,9 +972,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_command_args_strips_editor_runtime_fields_for_legacy_document_save() {
|
||||
fn convex_command_args_strips_editor_runtime_fields_for_legacy_page_body_save() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "documents.save".into(),
|
||||
command_name: "page.body.save".into(),
|
||||
command_id: "cmd_1".into(),
|
||||
function_name: "documents:updateContent".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
@@ -975,7 +989,7 @@ mod tests {
|
||||
"content": [],
|
||||
"expectedRevision": 0,
|
||||
"conflictDetectionKey": "doc_1:0",
|
||||
"editorDocument": {"rootBlockIds": []},
|
||||
"editorDocument": {"documentId": "doc_1", "rootBlockIds": []},
|
||||
"tiptapDocument": {"type": "doc", "content": []},
|
||||
"streamDeltaHint": {"family": "tree"},
|
||||
"domainEventHint": {"eventType": "page.body.saved"},
|
||||
@@ -1133,7 +1147,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_resource_lifecycle_args_keep_effective_user_id() {
|
||||
fn convex_cloud_media_resource_lifecycle_args_keep_effective_user_id() {
|
||||
let plan = RuntimeCommandExecutionPlan {
|
||||
command_name: "tree.resource.archive".into(),
|
||||
command_id: "cmd_resource_archive_1".into(),
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const WAIVER = "local-first-allow-convex-main-storage";
|
||||
|
||||
const RUNTIME_FORBIDDEN = [
|
||||
{
|
||||
name: "未标注的 Convex documents 直连",
|
||||
pattern: /documents:(?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex media 直连",
|
||||
pattern: /mediaAssets:(?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex AI session 直连",
|
||||
pattern:
|
||||
/aiSessions:(?:upsertRuntimeRun|appendRuntimeEvent|getRuntimeRun|listRuntimeRuns|listRuntimeEvents|renameRuntimeSession|autoTitleRuntimeSession|deleteRuntimeSession|searchRuntimeSessions)\b/,
|
||||
},
|
||||
{
|
||||
name: "Convex media URL 主路径",
|
||||
pattern: /\/api\/media\/(?:upload|sign|batch)|assetId=/,
|
||||
},
|
||||
];
|
||||
|
||||
const DESIGN_DEFAULT_CONVEX = /(?:Convex|convex).*(?:默认|default).*(?:主存储|主数据层|数据真相|页面正文真相|文件树真相|附件|AI 会话|session)/;
|
||||
const DESIGN_NEGATION =
|
||||
/(?:不应|不得|不再|禁止|只作为|降级|控制面|检查|若把|应要求改成|不是|不作为|不依赖|可选|sync replica|cloud source|compat|依赖证据|代码盘点|未完成|部分完成|当前状态)/;
|
||||
const ACTIVE_CONVEX_FORBIDDEN = [
|
||||
{
|
||||
name: "未标注的 Convex documents 表",
|
||||
pattern: /\bdocuments\s*:\s*defineTable\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex media_assets 表",
|
||||
pattern: /\bmedia_assets\s*:\s*defineTable\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex documents 函数",
|
||||
pattern: /export const (?:createWithParentReference|updateTitle|move|updateContent|getMeta|getContent|purge|listByWorkspace)\b/,
|
||||
},
|
||||
{
|
||||
name: "未标注的 Convex mediaAssets 函数",
|
||||
pattern: /export const (?:generateUploadUrl|createWithStorage|getById|refreshUrl|patchById|emptyTrashByWorkspace|purgeById|listByIds)\b/,
|
||||
},
|
||||
];
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"用法:",
|
||||
" node scripts/check-local-first-convex-guard.js",
|
||||
" node scripts/check-local-first-convex-guard.js --base origin/main",
|
||||
" node scripts/check-local-first-convex-guard.js --staged",
|
||||
" node scripts/check-local-first-convex-guard.js --files <path...>",
|
||||
" node scripts/check-local-first-convex-guard.js --self-test",
|
||||
"",
|
||||
`如需保留明确的 cloud/compat 直连,请在同一新增行加入 ${WAIVER}。`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = {
|
||||
base: "HEAD",
|
||||
staged: false,
|
||||
files: [],
|
||||
selfTest: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--staged") {
|
||||
result.staged = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--self-test") {
|
||||
result.selfTest = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--base") {
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error("--base 需要一个 git ref");
|
||||
result.base = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--files") {
|
||||
result.files = argv.slice(index + 1);
|
||||
break;
|
||||
}
|
||||
throw new Error(`未知参数:${arg}\n${usage()}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isDesignPath(filePath) {
|
||||
return filePath.startsWith("design/") && !filePath.startsWith("design/old/") && filePath.endsWith(".md");
|
||||
}
|
||||
|
||||
function isRuntimePath(filePath) {
|
||||
return filePath.startsWith("rust/crates/mnote-web/src/") && filePath.endsWith(".rs");
|
||||
}
|
||||
|
||||
function isActiveConvexPath(filePath) {
|
||||
return filePath.startsWith("convex/") && filePath.endsWith(".ts") && !filePath.startsWith("convex/_generated/");
|
||||
}
|
||||
|
||||
function isAllowedRuntimeAdapter(filePath) {
|
||||
return (
|
||||
filePath === "rust/crates/mnote-web/src/transport/convex.rs" ||
|
||||
filePath === "rust/crates/mnote-web/src/routes/compat.rs"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return filePath.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function readDiffLines(options) {
|
||||
const args = ["diff", "--unified=0", "--no-ext-diff"];
|
||||
if (options.staged) {
|
||||
args.push("--cached");
|
||||
} else {
|
||||
args.push(options.base);
|
||||
}
|
||||
args.push("--");
|
||||
const output = execFileSync("git", args, { encoding: "utf8" });
|
||||
return output.split(/\r?\n/);
|
||||
}
|
||||
|
||||
function collectAddedLinesFromDiff(lines) {
|
||||
const added = [];
|
||||
let currentFile = null;
|
||||
let newLine = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
|
||||
if (fileMatch) {
|
||||
currentFile = fileMatch[1];
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+++ /dev/null")) {
|
||||
currentFile = null;
|
||||
continue;
|
||||
}
|
||||
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunkMatch) {
|
||||
newLine = Number(hunkMatch[1]);
|
||||
continue;
|
||||
}
|
||||
if (!currentFile || line.startsWith("diff --git") || line.startsWith("index ")) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||
added.push({ filePath: currentFile, lineNumber: newLine, text: line.slice(1) });
|
||||
newLine += 1;
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("-")) {
|
||||
newLine += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
function collectLinesFromFiles(files) {
|
||||
const rows = [];
|
||||
for (const rawPath of files) {
|
||||
const filePath = normalizePath(rawPath);
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
rows.push({ filePath, lineNumber: index + 1, text: line });
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function checkLine(row) {
|
||||
if (row.text.includes(WAIVER)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isDesignPath(row.filePath)) {
|
||||
if (DESIGN_DEFAULT_CONVEX.test(row.text) && !DESIGN_NEGATION.test(row.text)) {
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
reason: "设计稿新增口径把 Convex 描述成默认主存储;请改成 LocalFS/WorkspaceSource 默认,或明确标注 cloud/control-plane/compat。",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!isRuntimePath(row.filePath) || isAllowedRuntimeAdapter(row.filePath)) {
|
||||
if (!isActiveConvexPath(row.filePath)) {
|
||||
return [];
|
||||
}
|
||||
return ACTIVE_CONVEX_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
|
||||
...row,
|
||||
reason: `${entry.name} 出现在 active convex 目录;新增 Convex 代码只能是 auth、membership、share grants、sync state、AI policy、cloud source、compat 或 sync replica,并需用 ${WAIVER} 标注例外。`,
|
||||
}));
|
||||
}
|
||||
|
||||
return RUNTIME_FORBIDDEN.filter((entry) => entry.pattern.test(row.text)).map((entry) => ({
|
||||
...row,
|
||||
reason: `${entry.name} 出现在非 Convex adapter 路径;请先走 WorkspaceSource / Rust kernel / LocalFS executor,或加 ${WAIVER} 并说明原因。`,
|
||||
}));
|
||||
}
|
||||
|
||||
function runSelfTest() {
|
||||
const rows = [
|
||||
{
|
||||
filePath: "convex/schema.ts",
|
||||
lineNumber: 1,
|
||||
text: " documents: defineTable({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/mediaAssets.ts",
|
||||
lineNumber: 1,
|
||||
text: "export const generateUploadUrl = mutation({",
|
||||
},
|
||||
{
|
||||
filePath: "convex/shareGrants.ts",
|
||||
lineNumber: 1,
|
||||
text: "export const upsertShareGrant = mutation({",
|
||||
},
|
||||
{
|
||||
filePath: "rust/crates/mnote-web/src/routes/example.rs",
|
||||
lineNumber: 1,
|
||||
text: '"documents:updateContent"',
|
||||
},
|
||||
{
|
||||
filePath: "rust/crates/mnote-web/src/transport/convex.rs",
|
||||
lineNumber: 1,
|
||||
text: '"documents:updateContent"',
|
||||
},
|
||||
];
|
||||
const violations = rows.flatMap(checkLine);
|
||||
const labels = violations.map((item) => item.filePath);
|
||||
if (!labels.includes("convex/schema.ts")) {
|
||||
throw new Error("self-test 失败:未拦截 active convex documents 表");
|
||||
}
|
||||
if (!labels.includes("convex/mediaAssets.ts")) {
|
||||
throw new Error("self-test 失败:未拦截 active convex media 函数");
|
||||
}
|
||||
if (!labels.includes("rust/crates/mnote-web/src/routes/example.rs")) {
|
||||
throw new Error("self-test 失败:未拦截非 adapter Rust Convex 直连");
|
||||
}
|
||||
if (labels.includes("convex/shareGrants.ts")) {
|
||||
throw new Error("self-test 失败:误拦截控制面 share grants");
|
||||
}
|
||||
if (labels.includes("rust/crates/mnote-web/src/transport/convex.rs")) {
|
||||
throw new Error("self-test 失败:误拦截 Convex adapter");
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, guard: "local-first-convex", selfTest: true }, null, 2));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.selfTest) {
|
||||
runSelfTest();
|
||||
return;
|
||||
}
|
||||
const rows = options.files.length > 0 ? collectLinesFromFiles(options.files) : collectAddedLinesFromDiff(readDiffLines(options));
|
||||
const violations = rows.flatMap(checkLine);
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("local-first Convex guard 发现新增主存储绑定:");
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation.filePath}:${violation.lineNumber} ${violation.reason}`);
|
||||
console.error(` ${violation.text.trim()}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, checkedLines: rows.length, guard: "local-first-convex" }, null, 2));
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000";
|
||||
const UI_TIMEOUT_MS = Number(process.env.UI_TIMEOUT_MS || 10_000);
|
||||
|
||||
function resolveChromiumExecutablePath() {
|
||||
const explicit = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "";
|
||||
if (explicit && fs.existsSync(explicit)) return explicit;
|
||||
return [
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/google-chrome",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].find((candidate) => fs.existsSync(candidate)) || "";
|
||||
}
|
||||
|
||||
function fileUrl(filePath) {
|
||||
return `file://${filePath.split(path.sep).map((part, index) => (
|
||||
index === 0 ? "" : encodeURIComponent(part)
|
||||
)).join("/")}`;
|
||||
}
|
||||
|
||||
async function waitForText(page, selector, expected) {
|
||||
await page.waitForFunction(
|
||||
({ selector: targetSelector, expectedText }) => {
|
||||
const node = document.querySelector(targetSelector);
|
||||
return Boolean(node && node.textContent && node.textContent.includes(expectedText));
|
||||
},
|
||||
{ selector, expectedText: expected },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
return page.locator(selector).innerText({ timeout: UI_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-admin-access-policy-"));
|
||||
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "README.md"), "# Admin Smoke\n", "utf8");
|
||||
|
||||
const executablePath = resolveChromiumExecutablePath();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
});
|
||||
|
||||
const adminContext = await browser.newContext({
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "admin_smoke",
|
||||
"x-mnote-actor-type": "admin",
|
||||
},
|
||||
});
|
||||
const adminPage = await adminContext.newPage();
|
||||
const readerContext = await browser.newContext({
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "reader_smoke",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const readerPage = await readerContext.newPage();
|
||||
|
||||
const grantId = `grant_${Date.now()}`;
|
||||
const rootUri = fileUrl(root);
|
||||
|
||||
try {
|
||||
await adminPage.goto(`${BASE_URL}/admin/access-policy`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
assert(
|
||||
await adminPage.locator('[data-testid="mnote-admin-policy-path"]').innerText(),
|
||||
"管理页应显示策略路径",
|
||||
);
|
||||
|
||||
await adminPage.locator('[data-testid="mnote-admin-root-uri"]').fill(rootUri);
|
||||
await adminPage.locator('[data-testid="mnote-admin-validate-root-submit"]').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const validateResult = await waitForText(
|
||||
adminPage,
|
||||
'[data-testid="mnote-admin-validate-result"]',
|
||||
root,
|
||||
);
|
||||
assert(validateResult.includes(root), "验证目录结果应包含 canonical root");
|
||||
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-id"]').fill(grantId);
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-user-id"]').fill("reader_smoke");
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-root-uri"]').fill(rootUri);
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-permission"]').selectOption("read");
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-recursive"]').check();
|
||||
await adminPage.locator('[data-testid="mnote-admin-grant-capabilities"]').fill("ai,share");
|
||||
await adminPage.locator('[data-testid="mnote-admin-create-grant-submit"]').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const createResult = await waitForText(
|
||||
adminPage,
|
||||
'[data-testid="mnote-admin-create-result"]',
|
||||
grantId,
|
||||
);
|
||||
assert(createResult.includes(grantId), "创建结果应返回 grantId");
|
||||
|
||||
await readerPage.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const openResponse = await readerPage.evaluate(async ({ rootUriValue }) => {
|
||||
const url = new URL("/api/local-folder/files/open", window.location.origin);
|
||||
url.searchParams.set("rootUri", rootUriValue);
|
||||
url.searchParams.set("path", "README.md");
|
||||
const response = await fetch(url.toString(), { credentials: "include" });
|
||||
return { status: response.status, text: await response.text() };
|
||||
}, { rootUriValue: rootUri });
|
||||
assert.equal(openResponse.status, 200, "read grant 应可打开本地文件");
|
||||
assert(openResponse.text.includes("Admin Smoke"), "打开的文件内容应正确");
|
||||
|
||||
const writeResponse = await readerPage.evaluate(async ({ rootUriValue }) => {
|
||||
const response = await fetch("/api/page-body/write", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
documentId: "local-md:README.md",
|
||||
workspaceId: "local-ws-admin-smoke",
|
||||
sourceKind: "local_folder",
|
||||
rootUri: rootUriValue,
|
||||
contentFormat: "editorBlocks",
|
||||
content: [],
|
||||
}),
|
||||
});
|
||||
return { status: response.status, text: await response.text() };
|
||||
}, { rootUriValue: rootUri });
|
||||
assert.equal(writeResponse.status, 403, "read grant 不应允许写入");
|
||||
|
||||
await adminPage.locator('[data-testid="mnote-admin-delete-grant-id"]').fill(grantId);
|
||||
await adminPage.locator('[data-testid="mnote-admin-delete-grant-submit"]').click({
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
const deleteResult = await waitForText(
|
||||
adminPage,
|
||||
'[data-testid="mnote-admin-delete-result"]',
|
||||
grantId,
|
||||
);
|
||||
assert(deleteResult.includes(grantId), "删除结果应返回 grantId");
|
||||
|
||||
await adminPage.waitForFunction(
|
||||
({ selector, removedGrantId }) => {
|
||||
const node = document.querySelector(selector);
|
||||
return Boolean(node && node.textContent && !node.textContent.includes(removedGrantId));
|
||||
},
|
||||
{ selector: '[data-testid="mnote-admin-policy-json"]', removedGrantId: grantId },
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const policyJson = await adminPage.locator('[data-testid="mnote-admin-policy-json"]').innerText();
|
||||
assert(!policyJson.includes(grantId), "删除后策略面板不应再包含已删授权");
|
||||
} finally {
|
||||
await adminContext.close().catch(() => {});
|
||||
await readerContext.close().catch(() => {});
|
||||
await browser.close().catch(() => {});
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task451-local-markdown-conflict-resolution-ui-smoke");
|
||||
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
const debug = {};
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function markdown(title, lines) {
|
||||
return [
|
||||
"---",
|
||||
`title: ${title}`,
|
||||
"---",
|
||||
"",
|
||||
...lines,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:task451`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function openDocument(page, root, relativePath) {
|
||||
await page.goto(documentUrl(root, relativePath), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForEditorText(page, text) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return (editor?.textContent || "").includes(expected);
|
||||
},
|
||||
text,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForEditorStatus(page, status) {
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||
return root?.getAttribute("data-runtime-editor-status") === expected;
|
||||
},
|
||||
status,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function typeDirtyText(page, text) {
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.type(text, { delay: 8 });
|
||||
await waitForEditorText(page, text.trim());
|
||||
}
|
||||
|
||||
async function waitForFileText(filePath, expected) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
||||
if (content.includes(expected)) return content;
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
throw new Error(`文件未出现期望内容: ${expected}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-local-conflict-ui-"));
|
||||
const acceptFile = "accept-disk.md";
|
||||
const keepFile = "keep-current.md";
|
||||
writeWorkspaceManifest(root, "user_real");
|
||||
fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept"]), "utf8");
|
||||
fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep"]), "utf8");
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 860 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
debug.network = [];
|
||||
page.on("response", async (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes("/api/page-body/write") && !url.includes("/api/documents/save")) return;
|
||||
let body = "";
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch (_) {
|
||||
body = "";
|
||||
}
|
||||
debug.network.push({ url, status: response.status(), body: body.slice(0, 800) });
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
const url = request.url();
|
||||
if (!url.includes("/api/page-body/write") && !url.includes("/api/documents/save")) return;
|
||||
debug.network.push({ url, failed: request.failure()?.errorText || "request_failed" });
|
||||
});
|
||||
const steps = [];
|
||||
|
||||
try {
|
||||
await openDocument(page, root, acceptFile);
|
||||
await waitForEditorText(page, "initial accept");
|
||||
const localAcceptToken = `local-accept-${Date.now()}`;
|
||||
const diskAcceptToken = `disk-accept-${Date.now()}`;
|
||||
await typeDirtyText(page, ` ${localAcceptToken}`);
|
||||
fs.writeFileSync(path.join(root, acceptFile), markdown("Accept Disk", ["initial accept", diskAcceptToken]), "utf8");
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-open-diff"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-diff-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForFunction(
|
||||
(expected) => {
|
||||
const panel = document.querySelector('[data-testid="mnote-conflict-diff-panel"]');
|
||||
return (panel?.textContent || "").includes(expected);
|
||||
},
|
||||
diskAcceptToken,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
const diffText = await page.locator('[data-testid="mnote-conflict-diff-panel"]').innerText({ timeout: UI_TIMEOUT_MS });
|
||||
debug.acceptDiffText = diffText;
|
||||
assert(diffText.includes(localAcceptToken), "diff 应显示当前编辑器版本");
|
||||
assert(diffText.includes(diskAcceptToken), "diff 应显示磁盘版本");
|
||||
await page.locator('[data-testid="mnote-conflict-accept-disk"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await waitForEditorText(page, diskAcceptToken);
|
||||
steps.push({ label: "accept-disk", ok: true });
|
||||
|
||||
await openDocument(page, root, keepFile);
|
||||
await waitForEditorText(page, "initial keep");
|
||||
const localKeepToken = `local-keep-${Date.now()}`;
|
||||
const diskKeepToken = `disk-keep-${Date.now()}`;
|
||||
await typeDirtyText(page, ` ${localKeepToken}`);
|
||||
fs.writeFileSync(path.join(root, keepFile), markdown("Keep Current", ["initial keep", diskKeepToken]), "utf8");
|
||||
await waitForEditorStatus(page, "external-change-conflict");
|
||||
await page.locator('[data-testid="mnote-editor-conflict-panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('[data-testid="mnote-conflict-keep-current"]').click({ timeout: UI_TIMEOUT_MS });
|
||||
await waitForFileText(path.join(root, keepFile), localKeepToken);
|
||||
const keepContent = fs.readFileSync(path.join(root, keepFile), "utf8");
|
||||
assert(!keepContent.includes(diskKeepToken), "保留当前版本后磁盘版本内容不应覆盖当前编辑器内容");
|
||||
steps.push({ label: "keep-current", ok: true });
|
||||
|
||||
const result = { ok: true, baseUrl: BASE_URL, root, steps };
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||
console.log(`task451 local markdown conflict resolution UI smoke passed: ${RESULT_PATH}`);
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(RESULT_PATH, `${JSON.stringify({
|
||||
ok: false,
|
||||
error: String(error && error.stack || error),
|
||||
debug,
|
||||
}, null, 2)}\n`, "utf8");
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user