diff --git a/.gitignore b/.gitignore index 15daff5d..143ff6f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ -node_modules/ -**/node_modules/ -.next/ -**/.next/ -dist/ -**/dist/ -build/ -**/build/ -.turbo/ +node_modules/ +**/node_modules/ +.next/ +**/.next/ +dist/ +**/dist/ +build/ +**/build/ +.turbo/ pnpm-debug.log* npm-debug.log* yarn-debug.log* @@ -30,50 +30,58 @@ env-archive/ /.pi/ /tmp-block-handle-qa.js +# Retired root-level agent/harness/browser evidence artifacts. +/REASONIX.md +/reasonix.toml +/playwright-snapshot-auth.md +/auth-console.txt +/auth-network.txt +/harness-init.sh + *.local tmp/ temp/ -**/.venv/ -.venv/ -**/__pycache__/ -__pycache__/ -# Convex 本机开发临时目录 -**/.convex-tmp/ -# 大模型文件,防止超出 LFS 配额 -services/mineru/models/ -*.safetensors -# ONLYOFFICE 挂载/同步目录(体积很大,禁止入库) -/onlyoffice-web-apps/ -/onlyoffice-plugins/ -/onlyoffice-data/ -/onlyoffice-fonts/ -/onlyoffice-pg-data/ -# ONLYOFFICE 现已移至 src/components/onlyoffice 下,整目录忽略 -/src/components/onlyoffice/ -# 参考资料目录 -cankao/ -# services 整体忽略(包含本地模型与临时服务) -/services/ -**/test/ -pw-tests/** -pw-tests/ -wolai-frontend/public/documents/ -wolai-frontend/public/documents/** -artifacts/ -artifacts/** -tmp - -# Rust 本地构建与调试产物 -/rust/target/ -/rust/spikes/**/target/ - -# Harness 本地运行状态/调试产物 -/.harness-active -/.harness-stop-counter -/harness-tasks.json.bak - -# pnpm 本地缓存 -/.pnpm-store/ +**/.venv/ +.venv/ +**/__pycache__/ +__pycache__/ +# Convex 本机开发临时目录 +**/.convex-tmp/ +# 大模型文件,防止超出 LFS 配额 +services/mineru/models/ +*.safetensors +# ONLYOFFICE 挂载/同步目录(体积很大,禁止入库) +/onlyoffice-web-apps/ +/onlyoffice-plugins/ +/onlyoffice-data/ +/onlyoffice-fonts/ +/onlyoffice-pg-data/ +# ONLYOFFICE 现已移至 src/components/onlyoffice 下,整目录忽略 +/src/components/onlyoffice/ +# 参考资料目录 +cankao/ +# services 整体忽略(包含本地模型与临时服务) +/services/ +**/test/ +pw-tests/** +pw-tests/ +wolai-frontend/public/documents/ +wolai-frontend/public/documents/** +artifacts/ +artifacts/** +tmp + +# Rust 本地构建与调试产物 +/rust/target/ +/rust/spikes/**/target/ + +# Harness 本地运行状态/调试产物 +/.harness-active +/.harness-stop-counter +/harness-tasks.json.bak + +# pnpm 本地缓存 +/.pnpm-store/ .playwright-mcp rust/spikes/leptos-tiptap-spike/trunk-8123.err rust/spikes/leptos-tiptap-spike/trunk-8123.out @@ -85,16 +93,22 @@ design/05-editor-mainline/reference-code /ai-sessions/ /filetree-*.png /task*-*.png +/qa-*.png # 本地 Wolai 对标取证与截图产物 /test-results/ /wolai-*.png /wolai-*.md - -# 任务型浏览器 smoke 只作本地验收,不纳入项目运行面 -/scripts/task*-wolai-*.js -/scripts/task*-rust-web-wolai-*-smoke.js - -# 下载的 Wolai 静态页面参考包,不作为可维护设计稿入库 -design/design/html/ + +# 任务型浏览器 smoke 只作本地验收,不纳入项目运行面 +/scripts/task*-wolai-*.js +/scripts/task*-rust-web-wolai-*-smoke.js + +# 本地一次性诊断脚本与截图取证(不入库) +/scripts/_diag-*.js +/scripts/*-diag/ +/scripts/page-tree-expand-diag/ + +# 下载的 Wolai 静态页面参考包,不作为可维护设计稿入库 +design/design/html/ recycle diff --git a/AGENTS.md b/AGENTS.md index 1e324b12..7a993ec1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,195 +2,126 @@ ## 当前主线 -- 当前长期方向以 `tree-first graph kernel` 为准,不以 `BlockNote-first` 或 `Mindmap-first` 为准。 -- 当前已经进入 local-first MVP 后阶段:最小可用闭环已建立,后续重点是底座统一、compat 瘦身和产品化闭环,不再把新增能力建立在旧 Convex / Next / BlockNote 主链上。 -- 当前产品形态固定为:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub + LightRAG AI + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 -- 本地文件夹是早期产品默认数据真相;本地 `.md` 是页面正文真相。Turso/libSQL `control-plane` 是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;`Convex` / 服务端只保留为历史迁移源、显式 cloud source、compat 和 sync replica 边界,不再作为新增能力的默认正文、附件、AI 会话全文主存储或默认控制面。旧 ACP/Hermes runtime session 只保留为 legacy/debug/兼容边界。 -- `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层。 -- `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat` 与 `fixture` 只用于过渡和测试,不应继续承载长期业务语义。`3000` 是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界。`/tree`、`/document-debug` 等 debug 壳默认关闭,仅在显式 debug/runtime 验证时启用。 -- 前端 runtime 已完成第一轮模块级拆分:`layout.rs` / `tree.rs` / `web_shell.rs` 不再是大型内嵌 JS 的主要定位入口。查 Sidebar、FileTree、tree shell、document adapter 等浏览器功能时,默认先看 `rust/crates/mnote-web/browser/*.js`;查 tiptap island 行为时,默认先看 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`,再回到根 `lib.rs` / Rust route。 -- 前端主路径应消费稳定 projection,不应在 UI 层重新拼出第二份对象真相。 -- Next `documents/page` 读取主链已优先消费 Rust `mnote.page_aggregate.v1` 快照;TS `page-aggregate-builder` 仅保留为历史 adapter / test helper,不再作为 runtime fallback,也不再把“前端手工拼 `meta + content`”描述为当前主路径。 -- `3000` 当前主壳已接入 Rust Web WebSocket push 主链(`/api/realtime/ws`)+ SSE fallback(`/api/tree/events`)的 snapshot / delta / resync consumer,并已有 browser smoke 验证(2026-05-17 WS 迁移 `57ec8322`);后续收口重点是统一 live cache 与减少补偿链,而不是把它描述成“还没接 live stream”。 -- 文档页默认主编辑器已切到页面内 `leptos-tiptap` island;`BlockNote` 已退出文档页默认主路径,只保留为历史参考实现 / 对照材料。 -- Page Aggregate 当前已输出 `blockDocument / blockProjectionVersion / projectionSource`(`projectionSource=documents.content`),标题/正文/页面设置写入后 smoke 已通过(`task110`、`task-page-aggregate-body-sync-smoke`、`task-page-aggregate-options-sync-smoke`);页面/块 AI 旧 Hermes tools 读取和写入块投影只作为兼容证据,不再代表当前 Page AI 主路径;但这仍是从 `documents.content` / local markdown content 投影出来的过渡态,不是 EditorBlockDocument 原生落库完成态。客户端 `PageAggregateClientState` reducer 仍在,页面域单一真源未完全闭环。 -- 当前最优先的架构收口不是继续扩编辑器 UI,而是在 local-first MVP 基线上推进 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、`Page Aggregate` 单一真源收口、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback)和 OpenHub + LightRAG 深度融合、agent diff / 冲突合并 / 本地轻量搜索 / 分享同步产品化。AI 侧普通 Markdown 编辑主路径已收口为“当前文件引用 + selection + allowed roots + OpenHub/native agent patch/diff + watcher 同步”;知识库问答、资料 OCR / parse / index / RAG 主路径为 LightRAG + OpenHub tool facade,WeKnora 仅保留为历史设计、参考实现或备用 provider 边界;`mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.doc.apply_block_ops`、`mnote.block.*` 只作为 cloud / remote agent / compat fallback 或复杂结构辅助;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。 +- 方向:`tree-first graph kernel` + local-first MVP 后阶段(底座统一、compat 瘦身、产品化闭环)。 +- 产品形态:`VSCode 简化版工作区 + leptos-tiptap Markdown + Pi Rust Page AI + LightRAG + mindmap/office 插件 + Wolai 主题壳 + Turso/libSQL 控制面`。 +- 数据真相:本地 workspace folder;页面正文为 `.md`。控制面:Turso/libSQL(auth / membership / share / sync / AI policy / Pi scope)。 +- Page AI:Pi Lab 为 3000 默认且唯一产品级入口。ACP/Hermes/Reasonix、OpenHub、旧 `/api/hermes/*` 等为兼容/legacy,不得新增能力;退役须原子覆盖。 +- Web:`mnote-web` 为 3000 唯一公开入口。kernel 持有树/边/projection/command 语义;前端只消费稳定 projection。 +- 正文保存:`page.body.write` + 文件版本;AI 普通 Markdown:授权文件 + allowed roots + Pi patch/diff + watcher。知识库:LightRAG + `mnote.knowledge_rag.*`。 +- 架构正文:只读 `ARCHITECTURE.md`(`CURRENT_ARCHITECTURE.md` 为兼容指针)。执行稿:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。 ## 组件定位 -- `leptos-tiptap` island 是当前文档页默认主编辑区 runtime,但不是系统事实源。 -- `BlockNote` 是历史参考实现 / 对照材料,不再是运行时系统组件、默认回退编辑器或系统事实源。 -- `Mindmap` 是 `tree-first graph` 的一种视图和编辑挂件,不是对象真相层。 -- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。 -- `page-ai/block-edit-workflow` route 只保留为兼容门面或非 local source 的快捷路径,不作为 local-first 普通 Markdown 编辑默认主路径;local-first 下优先给 OpenHub/native agent 授权文件引用并让 agent 使用自身文件编辑能力。`mnote.doc.markdown_edit` 是 cloud / remote agent / compat fallback;`mnote.block.*` 保留为结构性辅助。 -- `LightRAG + OpenHub` 是当前默认知识库与 Page AI 融合方向,负责资料库问答、检索、引用和 MNote 宿主集成;MNote 维护 source registry、allowed roots / 权限、FileTree 灯号、资料库设置 UI、status UI、citation/open-reference 映射和 agent tool facade。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界;LiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 已退役到 legacy / recycle 边界,不再作为新增主路径。 -- 树域三层模型:`Resource Tree`(kernel 对象组织真源)→ `File Tree`(主组织投影,`{title}.md` 为页面正文行)→ `Page Tree`(导航投影,不持有结构真相)。涉及资源归属优先走 Rust kernel 的 `KernelObjectIdentity` / `KernelProjectionResourceKind`。 +| 组件 | 定位 | +|------|------| +| `leptos-tiptap` island | 文档页默认编辑 runtime;非对象真源 | +| Mindmap | tree-first 视图/挂件;非真相层 | +| OnlyOffice | 独立页面编辑器;经附件/资源进入 | +| LightRAG + Pi | 知识库与 Page AI 默认融合 | +| Resource → File → Page Tree | 对象真源 → 主组织投影 → 导航投影 | ## Runtime 模块定位 -- `mnote-web` 浏览器 runtime 的当前 canonical 入口是 `rust/crates/mnote-web/browser/`: - - Sidebar 装配:`sidebar-tree-runtime.js`,已拆出 workspace、page tree、filetree command/open/upload、attachment open、page settings、live apply 等模块。 - - FileTree 通用能力:`filetree-runtime.js`、`filetree-selection-runtime.js`、`filetree-context-menu-runtime.js`、`filetree-dnd-runtime.js`、`filetree-keyboard-runtime.js`。 - - Page AI sidebar host:`sidebar-page-ai-runtime.js`,owner 属于 `07-ai`,不归 `03-rust-web` tree/filetree runtime。 - - 文档页 host adapter:`document-editor-adapter-runtime.js`,已拆出 pane、resource tab、mindmap host、slash positioning、Tiptap conversion、session 等模块。 - - debug/internal tree shell:`tree-shell-runtime.js`,已拆出 page、filetree、picker、DOM、render、icons、state 等模块。 -- `leptos-tiptap` island 的当前 canonical 入口是 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/`;新增编辑器行为不得继续直接堆入 `lib.rs`,除非它确实是 wasm-bindgen entrypoint、Leptos 壳层信号编排或仍未形成稳定模块边界。 -- CodeGraph 查 JS 功能时应能直接定位这些 `.js` 文件中的顶层函数;若结果仍落到 Rust raw string 或大型旧壳,先检查是否查错历史 / recycle 路径或索引未同步。 +- Browser:`rust/crates/mnote-web/browser/` + - Sidebar:`sidebar-tree-runtime.js`(及 workspace / page tree / filetree / upload 等子模块) + - FileTree:`filetree-runtime.js`、`filetree-selection-runtime.js`、`filetree-context-menu-runtime.js`、`filetree-dnd-runtime.js`、`filetree-keyboard-runtime.js` + - Page AI host:`sidebar-page-ai-runtime.js`(owner `07-ai`) + - 文档 host:`document-editor-adapter-runtime.js` + - debug tree shell:`tree-shell-runtime.js`(默认关闭) +- Island:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/`(勿把新行为继续堆进根 `lib.rs`,除非 wasm entry / 薄壳) +- SSR 注入:`layout.rs` / `tree.rs` / `web_shell.rs` — 非大型 JS 功能定位入口 ## 目录优先级 -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/` - wolai-frontend(Next.js React 前端)已退役移入 recycle,不作为当前实现依据。页面、编辑器、Sidebar、树视图、阅读态、交互壳默认先看 Rust mnote-web SSR(`rust/crates/mnote-web/src/`)。 -- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/` - 已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。 -- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/convex/` - 根 `convex/` functions 源码已软删除到这里,只作为历史审计 / 迁移对照素材;不再作为 active deploy source。默认控制面已由 Turso/libSQL `control-plane` 承接。 -- `/mnt/Data1T/mnote/rust/crates/core-protocol/` - Kernel 类型、projection 协议、树/图核心术语先看这里。 -- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/` - Kernel query / command / subtree / graph traversal 入口先看这里。 -- `/mnt/Data1T/mnote/rust/crates/mnote-web/` - Rust Web route、transport、compat、tree shell、projection 分发先看这里。 -- `/mnt/Data1T/mnote/wolai-backend/app/` - 辅助后端、异步处理、接口配合先看这里。 -- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/infra/convex/` - Convex 自托管服务基础设施已软删除到回收区;只作为显式 cloud source / compat / sync replica 或历史数据导出的历史对照,不再被 `desktop:hot` / `dev:hot` 默认启动链路要求。 -- `/mnt/Data1T/mnote/src/components/onlyoffice/` - 只在处理 OnlyOffice 静态资源、插件或兼容问题时进入。 -- `/mnt/Data1T/mnote/recycle/` - 默认视为历史回收区,不作为当前实现依据,除非任务明确要求。 +| 路径 | 用途 | +|------|------| +| `rust/crates/core-protocol/` | Kernel 类型、projection 协议 | +| `rust/crates/bridge-runtime/` | query / command / traversal | +| `rust/crates/mnote-web/` | 3000 route、transport、projection、compat | +| `rust/crates/mnote-web/browser/` | 浏览器 runtime JS | +| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island | +| `wolai-backend/app/` | 辅助后端 / 异步 | +| `src/components/onlyoffice/` | OnlyOffice 静态与插件 | +| `recycle/` | 历史回收;默认不作为实现依据 | ## 架构约束 -- 涉及树、页面结构、文件树、Sidebar、阅读投影、搜索投影、引用边语义时,优先判断是否应落到 Rust kernel,而不是直接改前端拼装逻辑。 -- 前端可以做展示、交互和局部适配,但不要新增第二套树真相、排序真相或 projection 契约。 -- `mnote-web` 的 `compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。 -- 浏览器主链默认禁止新增基于 `setInterval`、周期 `setTimeout` 或轮询 fallback 的数据刷新、状态同步和资源存在性检查,尤其是页面树、文件树、文档页、附件、AI 面板等高频可见路径。优先使用 Rust realtime event stream、WebSocket/SSE、watcher、`tree:delta/resync`、`page-aggregate-synced`、`MutationObserver` 和显式 command result 驱动刷新;只有在明确标注的 legacy/debug/internal 边界下,才允许短期保留轮询,并且必须在设计/bug 说明里记录原因、退出条件和内存/CPU 风险。 -- 涉及文档页标题、页面设置、正文保存、页头与树一致性时,优先判断是否应收口到 `Page Aggregate`,不要继续在页面壳或 island 外侧拼第二份页面真相。 -- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。 -- 涉及 local-first AI 普通 Markdown 编辑时,优先沿“授权文件引用 + `AiAccessScope` + allowed roots + OpenHub/native agent 原生 patch/diff + 文件版本冲突模型 + watcher 同步”推进;涉及 cloud / remote agent / 复杂结构辅助时,才沿 `mnote.doc.*` / `mnote.block.*` legacy tool facade 与 Rust `EditorCommand` 推进。`mnote.page.save` 只作为页面级兜底写入工具。 -- 涉及资源归属(哪个页面拥有哪个 mindmap/附件)、object identity 和资源生命周期时,优先沿 Resource Tree → File Tree projection → `tree.resource.*` 命令面推进;`tree.*` 是正式命令面,`documents.*` 只视为兼容层。 -- 页面 AI 编辑当前 local-first 主路径为:页面定位到真实 `.md` 文件,MNote 计算 `AiAccessScope` / allowed roots / selection,OpenHub/native agent 在白名单目录内用自身 patch/diff/文件编辑能力写入,MNote 通过 watcher / refresh 同步 tiptap。Hermes/Reasonix 只保留为 legacy/debug/外部工作流边界。`mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback;`mnote.block.*` 保留为结构性辅助(拖拽排序等)。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。 -- Page AI / OpenHub / 知识库这类用户入口首屏必须优先保证真实任务区可用:对话、输入框、知识库列表/详情占主体空间;debug、scope、health、route guard 等诊断信息只能放在折叠诊断区、开发态或日志中,不得默认挤占用户输入和阅读区域。 -- MNote + OpenHub 深度融合的交互能力优先改 OpenHub 源码本体(React/FastAPI/服务边界),MNote 只提供 auth、scope、proxy、文件/引用回跳等宿主胶水;不得用 MNote 侧 DOM 注入、悬浮按钮或拦截脚本伪装成 OpenHub 原生能力。 -- OpenHub/opencode 等 legacy fallback 只能保留为显式 debug/internal 边界;普通用户 UI 不得出现切换到 fallback 的按钮、入口或引导文案。 -- 涉及知识库资料索引 / OCR / RAG 时,默认沿 OpenHub + LightRAG 与 provider-neutral `mnote.knowledge_rag.*` / `/api/knowledge-rag/*` 推进。WeKnora 只作为历史设计、参考实现或备用 provider 边界;新增功能不得再假设 WeKnora 是默认 provider。 -- 需要架构判断时,优先参考: - - `/mnt/Data1T/mnote/ARCHITECTURE.md` - - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` - - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` - - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.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/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` - - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` - - `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` - - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` - - `/mnt/Data1T/mnote/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md` - - `design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md` - - `/mnt/Data1T/mnote/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md` - - `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md` - - `/mnt/Data1T/mnote/design/10-review/done/10-current-mnote-ai-runtime-review-v1.md` - - `/mnt/Data1T/mnote/design/10-review/done/11-current-full-architecture-review-v1.md` - - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` - - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md` - - `/mnt/Data1T/mnote/design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md` - - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-23-sidebar-local-folder-resource-runtime-followup-v1.md` - - `/mnt/Data1T/mnote/design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` - - `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` - - `/mnt/Data1T/mnote/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` +- 树 / 页面结构 / 引用边:优先落 Rust kernel,禁止 UI 拼第二套真相。 +- `compat route` 只承接过渡流量,不堆长期业务。 +- 浏览器主链禁止新增 `setInterval` / 周期轮询刷新(树、文档、附件、AI 面板等);用 WS/SSE、watcher、command result、`MutationObserver`。legacy/debug 若保留轮询须写明原因与退出条件。 +- 标题 / 页面设置 / 正文:收口 Page Aggregate;不在壳外侧拼第二份页面真相。 +- 新建/重命名/移动/归档:`tree.*`;`documents.*` 仅兼容。 +- local-first AI 正文:文件引用 + `AiAccessScope` + Pi patch/diff + 版本冲突 + watcher;`mnote.doc.*` / `mnote.block.*` 仅 cloud/remote/compat 或结构辅助。Phase C 流式 apply 冻结。 +- Page AI / 知识库首屏:对话与任务区优先;debug/health 放折叠区。 +- Page AI 宿主改原生 Pi Lab runtime;禁止 DOM 注入伪装第三方;普通 UI 不得出现 fallback 切换入口。 +- 知识库:LightRAG + `mnote.knowledge_rag.*` / `/api/knowledge-rag/*`。 +- 架构判断优先:`ARCHITECTURE.md`,再查对应 `design/**` process/done 稿。 ## 设计稿目录规则 -- `design/01-*` 到 `design/10-*` 主线大类统一按目录表达状态:`process/` 放当前可执行项,`done/` 放已完成项,`reference/` 放参考材料,必要时用 `draft/` 放未成形草稿。 -- 主线设计稿在推进中、且可直接拆 checklist 执行时放到对应大类的 `process/`。 -- 主线设计稿在当前真实代码中已完成后,必须移动到对应大类的 `done/`。 -- 参考实现、review 证据、对照矩阵、候选合同、测试流程基线等不直接执行的材料必须移动到对应大类的 `reference/`,不要继续占用 `process/`。 -- 尚未形成可执行 checklist 的新设想先放 `draft/`;进入实施前必须拆成明确 owner、验收和归档条件。 -- 已被后续实现或更新稿明确覆盖、但又不属于 `done` 的旧主线稿,必须移动到 `design/old/` 并标记为 `[recycle]`,不要继续占用活跃 `process/`。 -- `design/old/` 下的历史废弃稿也统一按 `process/` 与 `done/` 分层,但标题继续标记 `[recycle]`。 -- `design/90-reference/` 只放参考资料,不参与 `process/done` 状态迁移。 +- `design/01-*` … `design/10-*`:`process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。 +- 已完成主线稿必须迁入 `done/`;被覆盖且非 done 的旧稿迁 `design/old/` 并标 `[recycle]`。 +- `design/90-reference/` 只放参考资料。 ## Bugs 目录规则 -- `bugs/` 默认镜像 `design/` 的主线分类方式,按对应大类放置缺陷。 -- 每个大类继续按 `process/` 与 `done/` 分层: - - `process/`:缺陷已确认存在,仍在修复或验证中 - - `done/`:缺陷已修复,且已有真实代码与验证证据 -- 新确认的缺陷先放 `process/`;修复完成后必须移动到同类目的 `done/`,不要在两个目录同时保留同一条缺陷。 -- 缺陷归类以真正 owner 和长期主线为准,不以表面症状命名: - - `sidebar/topbar/breadcrumb/文档页壳/Wolai 体验对齐` 优先归到 `05-editor-mainline/` - - `projection/row model/selection/focus/keyboard/DnD/tree command` 优先归到 `04-tree-domain/` -- 当发现bug时,不仅仅是修bug,因为我们当前还处于mvp,需要看bug后面是否反映的是系统的缺失,如果需要系统性的开发,可以写deisign。如果边界不清,应该让user确认。 +- 镜像 design 大类;`process/` 未修完,`done/` 已验证。 +- 按真正 owner 归类:壳/Sidebar/文档页体验 → `05-editor-mainline/`;projection/selection/DnD/tree command → `04-tree-domain/`。 +- MVP 阶段发现 bug 时判断是否系统缺口;边界不清先问用户。 + ## 协作边界 -- 仅修改与当前任务直接相关的文件。 -- 不要擅自恢复、覆盖、删除用户已有改动。 -- 若发现与当前任务无关的脏改动,保持不动;若怀疑会影响当前任务,先确认再处理。 -- 若当前问题只是 UI 表现异常,先确认是否是实验性 tree shell、compat 路径、轮询或 fallback 混入首屏主链,而不是直接怀疑 Convex 本身。 -- 若发现现有轮询或定时刷新链路,不要直接在其上继续叠加补丁;先确认是否能改成事件驱动、watcher/realtime 推送或命令结果定点刷新,再决定是否保留临时 fallback。 -- 子 agent 只能作为受控叶子 worker:任务书必须限定读写范围、验证命令、交付文件和禁止项;不得自行再启动 runner、subagent、额外 worktree 或修改任务书。Codex 主控必须复核 diff、handoff/result、验证证据和 git 状态后才能采纳;Reasonix/Hermes 只属于历史 provider 边界。 -- 主控必须维护当前 subagent 的任务书、写入范围和采纳状态。任何 `worker` 启动后到完成/取消前,都视为可能写入;主控不得同时手工修改同一文件范围,也不得再派第二个 worker 修改同一范围。 -- 主控不得因为自己已经手工完成同一范围就提前关闭仍在运行的实现型 subagent;若该 worker 的写入范围已失效,必须先 interrupt/send_input 明确取消并说明其结果不再采纳,随后等待 shutdown/完成通知。只有已完成、已取消且确认不会再写入、或用户明确要求停止时,才 close_agent。 -- 使用 subagent 的目标是缩短关键路径。委派前必须确认子任务不会阻塞主控下一步、不会与主控或其它 worker 写同一文件、且主控有明确的结果采纳点;否则主控直接做。若 subagent 结果晚到,主控必须标记“未采纳”或复核后合并,不能让后台结果与主线程补丁并存成冲突。 -- 进入最终测试或最终回复前,主控必须确认本轮仍在运行的实现型 subagent 已完成、已明确取消并停止,或其输出已被标记为不采纳;不得在后台 worker 仍可能写入时声称已完成。 +- 只改与任务直接相关的文件;不覆盖用户已有改动;无关脏改动保持不动。 +- UI 异常先查 debug shell / compat / 轮询是否混入主链。 +- 发现轮询先评估事件驱动替代,再决定是否保留临时 fallback。 +- Subagent 为受控叶子:任务书限定读写范围、验证命令、交付与禁止项;不得再启 runner/worktree 或改任务书。主控复核 diff/证据后才采纳。 +- 同一写入范围同时只允许一个 writer(主控或一个 worker);写入范围失效须先取消 worker 再改。 +- 最终测试/回复前确认实现型 subagent 已完成、已取消,或输出已标不采纳。 -## CodeGraph 使用 +## CodeGraph -- 当前项目已配置 CodeGraph MCP;结构性代码问题优先使用 `codegraph_*` 工具:`codegraph_search` 查符号,`codegraph_callers` / `codegraph_callees` 查调用关系,`codegraph_impact` 做影响分析,`codegraph_context` / `codegraph_explore` 获取聚焦上下文,`codegraph_files` 查看索引文件结构,`codegraph_status` 检查索引健康。 -- CodeGraph 是当前开发态代码图主工具;代码改动后运行 `codegraph sync .`,大范围重构、排除规则变化或索引异常时运行 `codegraph index . --force`。只有查找字面文本、日志字符串、注释原文,或已经明确目标文件时,才优先用 `rg` / 直接读文件。 -- 提交 git 前必须再次运行 `codegraph sync .`,随后用 `codegraph status .` 或 `codegraph_status` 确认没有 pending changes;若仍有 pending、索引异常或本轮改动包含大范围重构 / 排除规则变化,改跑 `codegraph index . --force` 后再提交。 +- 结构性代码问题优先 CodeGraph MCP(search / callers / callees / impact / explore)。 +- 改代码后 `codegraph sync .`;大重构或索引异常 `codegraph index . --force`。 +- 提交前再 sync,确认无 pending;字面文本/日志字符串才优先用 `rg`。 -## Reference-Code 快速对照流程 +## Reference-Code -- 对照 VSCode / Sidex / Zed / Lapce / Tiptap 等参考实现时,先确认实际源码目录和对应 `.codegraph/` 是否存在;当前优先参考 `/mnt/Data1T/mnote/reference-code/sidex-main` 的完整 VSCode 工作台源码,`reference-code/vscode` 只作为裁剪版辅助证据。 -- 每次只围绕一个功能切面做对照,例如 Explorer 打开目标、编辑器 tab、资源生命周期、拖拽排序、快捷键或 overlay 定位;不要一次性抽象迁移整个参考项目。 -- 推荐步骤:`codegraph_status` 确认索引 -> `codegraph_search/context` 找参考实现入口 -> `codegraph_callers/callees` 看调用链 -> 对应查 MNote 当前链路 -> 映射到 Rust kernel / mnote-web / 前端 host 的正确边界 -> 补 smoke 或测试。 -- 对照结论必须区分“可直接采用的交互/状态模型”“只适合作参考的实现细节”“不符合 MNote local-first / tree-first 主线的内容”;不要把参考项目中的 UI 层状态当成 MNote 的新事实源。 +- 优先 `reference-code/sidex-main`(完整 VSCode 工作台);`reference-code/vscode` 为裁剪辅助。 +- 每次只对照一个切面;区分可直接采用的交互模型 vs 不符合 local-first/tree-first 的细节。 +- 不要把参考项目 UI 状态当成 MNote 新事实源。 ## 常用命令 -- 根目录热启动:`npm run desktop:hot` -- 前端开发(已迁移至 Rust SSR):`npm run desktop:hot` 启动后直接访问 `http://localhost:3000` -- 前端检查:Rust 测试 `cargo test -p mnote-web`(Rust SSR 与 API),旧 React 前端已移入 recycle -- 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000` -- Convex 自托管:仅在显式 cloud / compat / sync replica 或历史导出场景参考 `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/infra/convex/README.md` -- Control-plane 后端选择:默认 `MNOTE_CONTROL_PLANE_BACKEND=libsql-local`;云端开发使用 `MNOTE_CONTROL_PLANE_BACKEND=turso-remote` 或 `turso-local-replica` 并配置 `MNOTE_TURSO_DATABASE_URL` 与 `MNOTE_TURSO_AUTH_TOKEN`。`sqlite` 不再是 mnote-web 运行时后端,只保留给 control-plane-admin 迁移 / 导出和测试隔离。 -- Turso dev 数据库:[Turso CLI](https://docs.turso.tech/cli/installation) `turso db create mnote-dev`,创建 token 后设置环境变量即可。 +- 热启动:`npm run desktop:hot` → `http://localhost:3000` +- 测试:`cargo test -p mnote-web` +- 后端:`cd wolai-backend && uvicorn app.main:app --reload --port 8000` +- Control-plane:默认 `MNOTE_CONTROL_PLANE_BACKEND=libsql-local`;云端用 `turso-remote` / `turso-local-replica` + URL/token。`sqlite` 非 mnote-web 运行时。 -## Smoke 基线 +## Smoke 与测试账号 -- 当前 smoke 分类与默认基线以 `scripts/TESTING_REFERENCE.md` 为准。 -- 默认浏览器验证入口是 `3000 Rust SSR + leptos-tiptap island + local-first workspace + Turso/libSQL control-plane auth`。 -- **脚本纪律**:所有 smoke 和测试脚本不得直接 `sqlite3` CLI 写 control-plane DB;需要 seed 数据时必须走 Rust API、测试 seed endpoint、`scripts/lib/control-plane-dev-seed.js` 或 `scripts/lib/control-plane-test-env.js` helper。直写脚本必须标注理由和退出条件。 -- 退役 Convex / Next / 3104 / BlockNote 路径的 smoke 不作为默认回归基线;只有显式 legacy/cloud/compat 任务才运行对应脚本。 +- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + Turso/libSQL auth`。 +- 禁止 smoke 用 `sqlite3` CLI 直写 control-plane;seed 走 Rust API / `scripts/lib/control-plane-dev-seed.js` / `control-plane-test-env.js`。 +- 测试账号:`mnote.e2e@example.com` / `MnoteE2E123!` / `mnote-e2e`;优先 `/auth`「测试账号快速登录」,勿用 `MNOTE_DEV_AUTH=1` 跳过真实 auth。 -## 默认测试账号 +## 前端测试 -- 后续网页测试、浏览和 smoke 默认使用当前 SQLite control-plane Auth 测试账号:邮箱 `mnote.e2e@example.com`,密码 `MnoteE2E123!`,用户名 `mnote-e2e`。 -- 优先从 `http://localhost:3000/auth` 点击“测试账号快速登录”进入;若需手动注册,也必须注册同一组账号,不要改用 `MNOTE_DEV_AUTH=1` 跳过真实 auth。 -- 需要核验登录是否真实生效时,优先检查当前 `mnote_session` 是否能解析到 libSQL/Turso control-plane 用户会话,避免把 `devFallback` 当成真实登录;Convex JWT 只属于历史 / cloud compat 边界。 +- 看真实渲染:`/doko`;交互回归:浏览器自动化。 +- 主页/Sidebar/文档首屏优先复用 `scripts/task*-smoke.js`。 +- 高 CPU/内存:先查 debug shell、compat fallback、重复请求、轮询。 -## 前端测试方法 +## Wolai-aline -- 看页面当前真实渲染结果、登录态下实际内容、JS 渲染后的 `localhost` 页面时,优先用 `/doko`;它适合读取真实 Chrome 中已经渲染完成的页面。 -- 做交互测试、文件创建/删除/移动、上传、快速登录、侧边栏展开、回归断言时,优先用浏览器自动化测试工具;这类任务不要只靠 `/doko`。 -- 推荐顺序是:先用 `/doko` 快速确认页面是否正常渲染,再用浏览器自动化测试工具验证关键交互链路。 -- 影响主页入口、Sidebar、tree shell、文档页首屏时,优先补或复用 `scripts/task*-smoke.js` 这类 smoke 脚本。 -- 排查高 CPU / 高内存 / 卡顿时,先看是否存在首屏误走实验性 tree shell、compat fallback、重复请求、轮询或回链面板持续刷新,再看数据底座。 +- 启用 wolai-aline skill + `design/08-wolai-aline-test-flow/reference/wolai-aline-test-flow-v1.md`。 +- 流程:Wolai 基线(默认只读)→ 本地 RED smoke → 实现 → 验证 → subagent 浏览器对标 → 截图复核。 +- 浏览器对标用 subagent,只验证与截图;写入需明确授权与沙盒页。 +- 登录态:`tmp/wolai-playwright-profile`;滑块验证不绕过,记录阻塞。 +## 编码 -## Wolai-aline 对标流程 +- UTF-8;TS/TSX 2 空格;Python 4 空格。 -- 凡任务涉及 `wolai-aline`、Wolai 对标、复刻 Wolai 体验或把 `3000` 行为与 Wolai 页面比对,必须启用 `/home/lix/.codex/skills/wolai-aline` skill,并参考 `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/reference/wolai-aline-test-flow-v1.md`。 -- Wolai-aline 任务默认采用“Wolai 基线取证(默认只读,编辑器任务仅在明确授权的 editable-test mode 下执行)-> 本地 RED smoke -> 小范围实现 -> 本地验证 -> subagent 浏览器对标复测 -> 主线程截图复核 -> 汇报剩余差异”的流程。 -- 浏览器对标测试必须使用 subagent 执行;subagent 只做浏览器验证和截图,不修改源码、不还原文件、不清理证据;编辑器任务可在明确声明的 editable-test mode 下做最小编辑验证。 -- Wolai 默认优先只读;编辑器对标只有在用户明确给出测试页和授权时才可做最小范围编辑测试。其他 Wolai 页面写入仍需沙盒页 URL 和明确授权。 -- 即使在授权测试页,也禁止未经确认地删除、移动、归档、发布、评论、改权限或批量修改既有非测试内容;编辑测试必须记录前后截图、动作链、输入内容和清理状态。 -- 对标验收不能只看文案或 DOM 是否存在;必须检查截图中的控件形态、开启/关闭状态、hover/active 状态、快捷键行为、URL 是否跳转等真实体验差异。 -- 发现截图或实测行为与本地实现不一致时,先把差异补进 smoke 形成可复现失败,再修改实现并复测。 -- Wolai owner 登录态优先复用 `/mnt/Data1T/mnote/tmp/wolai-playwright-profile`;遇到登录或滑块验证,不绕过,记录阻塞并让用户介入。 +## 密码箱 / agent skills(薄指针) -## 文件编码与风格 - -- 所有新增或修改文件统一使用 UTF-8。 -- TS/TSX 默认 2 空格缩进,Python 默认 4 空格缩进。 +- 密码箱策略 SSOT:`/home/lix/.agent-infra/vault-policy.md`;skill `$mnote-vault`(`skills/mnote-vault`,全局 symlink 到 Codex/Hermes/Grok)。 +- Page AI pack 注册:`hermes_tools/skill.rs` id `mnote-vault`(仅放 `skills/` 不够)。 +- 勿在本文件复制完整 vault 策略;Paseo 经 `daemon.appendSystemPrompt` 全 agent 注入短段。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 467136ab..59478a0d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,474 +1,175 @@ -# MNOTE 当前架构梳理 +# MNote 架构 -> 更新时间:2026-06-07 -> -> 2026-05-22 口径更新:产品形态已切换为 local-first workspace,且初步 MVP 已建立;本地文件夹是默认数据真相,Turso/libSQL control-plane 承接 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 等默认控制面。旧 ACP/Hermes runtime session 只保留为迁移兼容层。Convex 仅保留为历史迁移源、显式 cloud source / compat / sync replica 边界;根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,不再作为 active deploy source。相关设计已完成并迁入 `design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md` 与 `design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`。本地 Markdown 图片与附件上传已新增 `/api/local-folder/assets/upload`,页面内上传默认写入 `{mdBase}.assets/` 并保存相对 Markdown 路径,不再走 Convex media asset。 -> -> **当前阶段:local-first MVP 后阶段。** 3000 下文档页、Sidebar/File Tree/Page Tree 三层树模型、tree command(`tree.*` preferred)、tree realtime WS 主链 / SSE fallback、本地 Markdown 上传、上传下载、AI 会话本地化与 VSCode-like agent 运行口径均已建立。剩余工作集中在 `WorkspacePath/ObjectIdentity` runtime 消费统一、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、VSCode-like 冲突合并 UI、agent diff 审计、本地轻量搜索 / 分享 / 同步闭环,而不是继续扩张专用 page-ai fast-path 或 Convex 主存储链。 -> -> 2026-05-26 口径更新:前端 runtime 大块已完成模块级拆分。`layout.rs` / `tree.rs` / `web_shell.rs` 不再是浏览器 JS 功能的主要符号定位入口;Sidebar、FileTree、document adapter、tree shell 等 runtime 入口位于 `rust/crates/mnote-web/browser/*.js`,tiptap island 入口位于 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`。CodeGraph 可直接索引这些 JS / Rust 模块;当前 smoke 分类见 `scripts/TESTING_REFERENCE.md`。 -> -> 2026-07-03 口径更新:当前 runtime 已收口为 OpenHub / native agent + LightRAG + Turso/libSQL。知识库问答、资料 ingestion / query / citation / open-reference 默认走 LightRAG provider;OpenHub 负责 Page AI 面板、opencode/native agent 执行、会话和工具事件承载;WeKnora 只保留为历史设计、参考实现或可选备用 provider 边界,不再作为当前默认知识库主链。Hermes / Reasonix 仅保留 legacy/debug/外部工作流边界。旧 `/api/hermes/*`、`hermes_tools/*`、ACP runtime 命名属于历史命名或迁移中的兼容层,不代表当前产品定义。 +> 更新时间:2026-07-19 +> 本文只描述**当前**产品形态、运行分层与收口缺口。历史迁移、退役路径与 changelog 不在此展开;协作规则见 `AGENTS.md`,执行 checklist 见 `design/`。 -本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。 - -## 1. 当前主线结论 - -当前仓库的主线不是: - -- `BlockNote-first` -- `Mindmap-first` -- “先拆掉 Convex 再谈 Rust” - -当前主线固定为: - -1. `tree-first graph kernel` 是长期对象真相层 -2. 本地文件夹是默认数据真相,Turso/libSQL control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面;Convex 退居历史迁移源与显式 cloud / compat 边界 -3. `mnote-web` 是当前 Rust Web 主执行面,负责 3000 gateway、server-first shell、query / command / projection / transport 与 realtime stream;Next App Router 已降为 legacy compat / island bundle source,不再是当前主入口 -4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island -5. `BlockNote` 已退出文档页默认主路径,仅作为历史参考实现 / 对照材料保留 -6. 页面 AI 当前最合理的长期形态是:MNote 只负责页面定位、白名单目录权限、OpenHub / native agent scope 管理、文件变更同步;OpenHub / native agent 直接在授权工作区内编辑本地文件。`page_ai_workflow` 只保留为 debug / 历史兼容门面,`mnote.doc.markdown_edit` 不作为 local-first 默认正文编辑入口,只在显式 cloud / remote / compat 且无法直接授权文件访问的边界中作为受控 fallback;`mnote.block.*` 只保留为复杂结构辅助层 -7. 知识库资料处理主线是 LightRAG + OpenHub:资料 ingestion、检索、引用和跨文档问答默认由 LightRAG provider 承担;OpenHub / native agent 通过 MNote provider-neutral tool facade 使用知识库能力;WeKnora 仅作为历史设计、参考实现或备用 provider 边界,MNote 不再并行维护 LiteParse / evidence SQLite / OCR sidecar 作为资料库索引真相 - -一句话收口: - -> **Rust 持有语义主导权,本地文件夹是默认数据真相,Turso/libSQL control-plane 持有默认控制面;Convex 只保留历史迁移源、显式 cloud source / compat / sync replica 能力,前端逐步从重壳转向消费稳定 projection 与少量交互 island。** - -补充口径: - -> **主 Web 执行面当前以 `mnote-web` 为 3000 owner;Next App Router 只保留为 legacy compat、交互 island bundle source 与显式 debug/迁移辅助边界。** -> -> **页面 AI 的 local-first 主路径应尽量贴近 VSCode:当前页面解析成真实 `.md` 文件,OpenHub / native agent 在授权目录白名单内直接读写,tiptap 只消费后台文件变化后的最新投影。知识库问答默认走 LightRAG provider,并经 MNote source registry 和 citation/open-reference 回到本地资源。** - -## 2. 当前主线目录 - -- `/mnt/Data1T/mnote/recycle/wolai-frontend/` - 已退役移入 recycle。React island 与 legacy compat 代码的历史来源;Next.js App Router 已被 Rust mnote-web SSR 替代,不再作为 3000 主入口。 -- `/mnt/Data1T/mnote/rust/crates/core-protocol/` - Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语。 -- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/` - Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。 -- `/mnt/Data1T/mnote/rust/crates/mnote-web/` - Rust Web route、kernel projection、documents/search/mindmap shell、OpenHub bridge、stream transport、compat,以及显式开启时的 debug shell;它是当前主 Web gateway / shell / transport owner。旧 Hermes bridge 命名仅作为 legacy route / tool facade 兼容层存在。 -- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/convex/` - 根 `convex/` functions 源码已退役并软删除到这里;不再作为 active deploy source。历史 `schema.ts`、`aiSessions.ts`、Auth functions 仅用于审计 / 迁移对照,默认控制面已由 Turso/libSQL control-plane 承接。 -- `/mnt/Data1T/mnote/recycle/wolai-frontend/convex/` - 已随 wolai-frontend 移入 recycle,不允许作为当前 Convex deploy fallback。 -- `/mnt/Data1T/mnote/recycle/20260522-convex-runtime-retirement/infra/convex/` - Convex 自托管服务基础设施已软删除到回收区;只作为显式 cloud source / compat / sync replica 或历史数据导出的历史对照,不再被 `desktop:hot` / `dev:hot` 默认启动链路要求。 -- `/mnt/Data1T/mnote/wolai-backend/` - 辅助后端与异步处理,不是当前页面主链。 - -## 3. 当前页面运行结构 - -### 3.1 根布局与 legacy island source(已退役) - -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/app/layout.tsx` - -该文件已随 wolai-frontend 移入 `recycle/`,不再作为当前实现依据。 - -注意: - -> **3000 当前根入口与文档页 shell 由 `mnote-web` Rust SSR 持有;Next App Router 已完全退役。** - -### 3.2 工作区壳与 Sidebar(已迁移至 Rust SSR) - -以下文件已随 wolai-frontend 移入 `recycle/`,不再作为当前实现依据: - -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/app-layout-shell.tsx` -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/hooks/use-sidebar-data.ts` -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts` -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts` - -当前 Sidebar/工作区壳的当前实现在 Rust mnote-web SSR: -- 工作区壳渲染入口:`gateway.rs`(`root_entry`) -- 文档页壳/文档面板:`web_shell.rs`(`document_page_shell`、`build_document_panes_bootstrap_json`) -- Sidebar / FileTree / tree live runtime:`rust/crates/mnote-web/browser/sidebar-tree-runtime.js`、`filetree-*.js`、`tree-live-controller.js` 等模块;`layout.rs` 只负责 SSR/bootstrap 和 script 注入。 -- FileTree 渲染与交互:`rust/crates/mnote-web/browser/filetree-runtime.js`、`filetree-selection-runtime.js`、`filetree-context-menu-runtime.js`、`filetree-dnd-runtime.js`、`filetree-keyboard-runtime.js`。 -- debug/internal tree shell runtime:`rust/crates/mnote-web/browser/tree-shell-runtime.js` 及 `tree-shell-*.js` 模块;`tree.rs` 只负责 route、HTML shell、asset route 和测试断言。 - -### 3.3 Runtime 模块化现状 - -当前 CodeGraph / IDE / 浏览器 stack trace 的定位入口应按模块查找: - -- `rust/crates/mnote-web/browser/document-editor-adapter-runtime.js`:文档页 host adapter entrypoint,子模块负责 pane、resource tab、mindmap host、slash position、Tiptap conversion 和 session runtime。 -- `rust/crates/mnote-web/browser/sidebar-tree-runtime.js`:Sidebar entrypoint,子模块负责 workspace、page tree、filetree open/command/upload、attachment open、page settings 和 live apply;Page AI sidebar host 位于 `sidebar-page-ai-runtime.js`,owner 属于 `07-ai`。 -- `rust/crates/mnote-web/browser/tree-shell-runtime.js`:debug/internal tree shell entrypoint,子模块负责 page tree、filetree、picker、render、DOM、icons 和 state。 -- `rust/spikes/leptos-tiptap-spike/src/editor_runtime/`:tiptap island runtime 模块集合;根 `lib.rs` 只保留 wasm entrypoint、Leptos shell 信号编排和仍未稳定外置的薄壳。 - -对应完成记录见 `design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md`。剩余专项边界不再回到 raw string 问题,而是分别由 `design/03-rust-web/reference/3-23-sidebar-local-folder-resource-runtime-followup-v1.md` 和 `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` 承接。 - -## 4. 文档页主链 - -### 4.1 入口 - -文档页入口的当前实现在 Rust mnote-web: - -- 文档页壳 SSR:`web_shell.rs`(`document_page_shell`),直接输出 HTML + bootstrap JSON -- Page Aggregate 快照 API:`web_shell.rs`(`page_aggregate`)返回 `/api/page-aggregate/:id` -- 前端桌面/窗口面板布局:`web_shell.rs`(`build_document_panes_bootstrap_json`) - -历史 wolai-frontend Next 文档页入口(已移入 `recycle/`): -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/page-aggregate-loader.ts` -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/app/(app)/documents/[id]/page.tsx` -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/page-aggregate.ts` - -### 4.2 文档壳(已迁移至 Rust SSR) - -历史 React 文档壳组件(已随 wolai-frontend 移入 `recycle/`,不再作为当前实现): -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/document-shell.tsx` -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/document-content.tsx` - -当前文档壳由 Rust mnote-web SSR 输出: -- `web_shell.rs` → `document_page_shell` 直接渲染 HTML shell,不在前端保留 React 文档壳 runtime。 - -### 4.3 默认编辑器 host(已迁移至 WASM island) - -历史 React 编辑器 host(已移入 `recycle/`): -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/editor-host-config.ts` -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/editor-host.tsx` -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx` - -当前默认主编辑器是页面内 Leptos/WASM island,由 Rust mnote-web SSR 加载: -- WASM 产物:`rust/spikes/leptos-tiptap-spike/` -- 加载路由:`web_shell.rs` → `leptos_tiptap_manifest` / `leptos_tiptap_asset` - -### 4.5 BlockNote 的当前位置 - -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/components/editor/blocknote-editor.tsx` - -`BlockNote` 已随 wolai-frontend 移入 recycle,仅作为历史对照材料,不参与当前实现。 - -而不是默认主编辑器方向。 - -## 5. 当前编辑器与页面聚合关系 - -当前文档页已经开始消费统一的 `PageAggregateProjection`,但仍不能说“页面域已完全统一成 Rust 单一真源”。 - -当前真实状态是: - -- 读取主链已优先消费 Rust `mnote.page_aggregate.v1` snapshot -- Page Aggregate 已输出 `blockDocument`、`blockProjectionVersion` 与 `projectionSource` -- 标题链路已开始与工作区树 canonical snapshot 对齐 -- 正文默认由 `leptos-tiptap` island 编辑与保存 -- 页面设置已有一部分进入 island 运行时语义 -- `page_tree` / `pageSubtree` 已进入统一聚合入口 -- 页面/块 AI tools 可通过 Page Aggregate block projection 读取、定位、dry-run、替换、插入和受限移动块;local-first 普通正文编辑不再要求走块工具,在线 / cloud / compat 场景才回到受控 mnote tool 写入。 - -2026-05-16 更新:以下四项已通过真实 3000 browser smoke 验证(证据见 `tmp/page-aggregate-*` 目录): - -- 标题写入后 Page Aggregate、页头、Breadcrumb、Sidebar、Page Tree、File Tree 一致性(`task110`) -- 正文保存后 `body.revision` / `conflictDetectionKey` / `blockDocument` 同步(`task-page-aggregate-body-sync-smoke`) -- 页面设置写入后 `layout.pageOptions` 与 island runtime DOM 同步,刷新后不退回(`task-page-aggregate-options-sync-smoke` / `task-page-aggregate-refresh-persistence-smoke`) -- Convex 不可用时返回 degraded error(503 + `x-error-code=convex_unavailable`),不返回伪 fixture(route 级负向测试) - -仍未完成的关键点是: - -1. Rust 侧已提供最小 `Page Aggregate` snapshot 和 block projection v1,但当前 block projection 仍主要从 `documents.content` / local markdown content 投影(`projectionSource=documents.content`),不是 EditorBlockDocument 原生落库完成态 -2. 标题 / 正文 / 页面设置虽已收口到 `page.*` family 和 local-first 文件版本模型,但客户端仍保留 `PageAggregateClientState` reducer(混合 server snapshot / draft title / local content),页面域单一真源仍未完全闭环 -3. AI 块工具继续作为复杂结构辅助;local-first 普通 Markdown 编辑主路径已经转为“授权文件引用 + agent 原生 patch/diff + watcher 同步” -4. Sidebar 仍通过 preferred snapshot(initial / query / tree_stream)做 freshness 选择,tree realtime live cache 未统一 - -因此当前正确表述应是: - -> **文档页读取主链已进入 Rust-first `page aggregate` 过渡态,标题/正文/页面设置 smoke 已通过,但页面域单一真源(ClientState → Rust snapshot)与 tree realtime live cache 仍未闭环。** - -## 6. 当前树域结构 - -### 6.1 Kernel projection 与 route - -- `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs` - -当前 Rust 已提供: - -- `sidebar_tree` -- `page_tree` -- `file_tree` -- `subtree` -- `edges` -- `graph` - -这些 route 说明树域 projection family 已经在 Rust 侧具备清晰边界。 - -### 6.2 树命令现状 - -- `/mnt/Data1T/mnote/recycle/wolai-frontend/src/lib/documents/tree-command-client.ts` -- `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/tree.rs` -- `/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs` - -当前树命令已经开始以 `tree.*` 为 preferred command name,例如: - -- `tree.node.create` -- `tree.node.rename` -- `tree.subtree.move` - -但兼容层仍广泛保留: - -- `documents.create` -- `documents.title.update` -- `documents.move` - -这说明 tree command cutover 已开始,但还没有完成。 - -### 6.3 树 realtime 现状 - -当前 Sidebar 已有: - -- query snapshot -- tree stream -- preferred snapshot 选择 - -正式的 snapshot + delta 主链已经固定到 `mnote-web` 的 `/api/tree/events`,Next `/api/mnote-web/stream` 只保留 compat alias。 - -因此当前正确表述应是: - -> **树域 realtime 主链由 Rust Web `/api/tree/events` 持有,前端只消费稳定 stream contract 与 projection。** - -补充: - -> **`3000` 主壳当前已不再通过 `/api/tree/projections/*` 额外维护第二条 runtime live fallback;snapshot / delta / resync 直接消费 `/api/tree/events` 载荷。** - -### 6.4 Resource Tree / File Tree / Page Tree 三层模型 - -当前树域已建立三层语义(合同见 `design/04-tree-domain/done/4-24-*` 与 `design/05-editor-mainline/done/5-12-*`): - -- **Resource Tree**:长期 canonical 对象组织树,Rust kernel 持有语义。资源类型包括 page、mindmap、attachment、onlyoffice、code 等(`KernelObjectIdentity`、`KernelProjectionResourceKind` 已在 `core-protocol` 落地)。 -- **File Tree**:Resource Tree 的主组织投影。展示页面文件夹、`{title}.md`(非 `index.md`)、mindmap、附件等资源。文件树行消费 Rust `/api/tree/projections/file` 或等价 projection,不在前端临时拼装第二真相。 -- **Page Tree**:面向阅读/导航的快捷投影。只显示页面关系和导航语义,不拥有排序、父子、附件归属的最终真相。 - -关键规则: - -- 文件树点击 mindmap 打开 mindmap object editor,不被 `{title}.md` 吞掉 -- `{title}.md` 只代表页面正文(Page Aggregate body),mindmap / OnlyOffice / 附件是不同 object model -- 页面树不持有独立结构真相,只显示文档导航关系 - -## 7. Mindmap 与 OnlyOffice - -### 7.1 Mindmap - -当前 Mindmap 不再是系统中心,而是: - -- `tree-first graph kernel` 的一种视图 / 编辑挂件 - -它仍有: - -- 文档内嵌块形态 -- 独立页面形态 - -但不应再被理解为对象真相层。 - -### 7.2 OnlyOffice - -OnlyOffice 仍然是: - -- 独立页面型编辑器 - -它不直接嵌入正文主编辑画布;正文中通常通过附件块跳转进入。 - -## 8. 当前最优先的架构收口 - -当前最优先的三条主线,不是继续大规模 UI 重写,而是: - -### 8.1 Page Aggregate - -目标: - -- 让标题 / 页面设置 / 正文 / page tree 统一成同一组 page aggregate projection 与 command family -- 前端不在页面壳、island 外侧、Sidebar preferred snapshot 外再拼第二份页面真相 - -当前进度(2026-05-16): - -- ✅ 文档页读取主链已 Rust-first,TS builder 退出 runtime,Next compat 返回 410 -- ✅ 标题单一真源 smoke 通过(`task110`):页头/Breadcrumb/Sidebar/Page Tree/File Tree 一致 -- ✅ 正文写入后 `body.revision` / `blockDocument` 同步 smoke 通过 -- ✅ 页面设置写入后 `pageOptions` 同步与刷新持久化 smoke 通过 -- ✅ local-first `page.body.write` / `/api/page-body/write` 与 `expectedFileVersion` 已成为本地正文写入主入口,`/api/documents/save` 降级为 compat adapter -- ✅ 本地 Markdown 图片 / 附件上传写入 sibling assets,并由真实浏览器 smoke 覆盖保存后刷新恢复 -- ✅ Convex 不可用时返回 degraded error,不返回伪 fixture -- ⬜ cloud / compat block projection 继续减少 `documents.content` 后备;local-first 正文真相已转为 `.md` 文件投影 -- ⬜ 客户端 `PageAggregateClientState` reducer 退役,页面域完全以 Rust snapshot 为单一运行时真相 - -对应设计稿: - -- `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` -- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` -- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md` - -### 8.2 Tree Command Cutover - -目标: - -- 让 `tree.*` 成为正式命令面 -- `documents.*` 降为兼容层 - -对应设计稿: - -- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md` - -### 8.3 Tree Realtime Event Stream - -目标: - -- 让 snapshot + delta 正式成为树域实时主链 -- 减少当前 query/refetch/freshness 补偿链的长期存在 - -对应设计稿: - -- `/mnt/Data1T/mnote/design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md` - -### 8.4 Page / Block AI Tooling - -目标: - -- 让 local-first 页面 AI 默认走“页面定位 -> 授权文件引用 -> agent 原生 patch/diff -> 前台同步” -- 把 `mnote.page.save` 固定为页面级兜底写入工具,不再代表普通正文编辑默认入口 -- 把 `mnote.doc.*` / `mnote.block.*` 收口为兼容 / cloud / 复杂结构辅助工具 -- 补齐 `scope=selection`、白名单目录权限、changed_files/diff 审计、前台同步刷新矩阵 - -当前进度(2026-05-19,按 `2-2` 完成态更新): - -- ✅ 页面 AI 与 ACP runtime 已能承载本地优先方向 -- ✅ `page_ai_workflow` 已在 local source 下退出主路径 -- ✅ legacy tool facade(`mnote.doc.*` / `mnote.block.*`)已可作为兼容 / cloud / 复杂结构辅助层读取和写入 -- ✅ 本地优先设计已经收口为 VSCode-like 运行模型:页面定位 + 白名单目录 + agent 直改文件 + tiptap 同步显示 -- ✅ 页面 AI 默认输入已收口到 `currentFile + selection + allowedRoots / aiAccessScope` -- ✅ legacy ACP runtime 曾显式带白名单目录运行;当前主线转向 OpenHub / native agent scope -- ⬜ MNote 回收 changed_files / diff 审计 -- ⬜ `scope=selection`、`format=page_xml/text` 继续作为结构化辅助输出,不作为普通 Markdown 编辑必需路径 -- ⬜ tool manifest annotations 继续补齐 destructive / requiresApproval 等高级语义 -- ⬜ **Phase C(设计冻结,不实施)**:`StreamApplyController` + `ReviewSession` + `GhostTextOverlay`(流式 apply + suggest/review) -- ⬜ stale revision / idempotency 重放保护(已有 revision 乐观锁覆盖,补端到端 smoke) - -关键原则(2026-05-18 口径更新): - -- **local-first 主路径**:当前页面解析成授权 `.md` 文件,OpenHub / native agent 在白名单目录内直接读写,MNote 负责权限、审计和前台同步 -- **local-first 文件编辑模型**:普通 Markdown 编辑走“页面定位 + 授权文件引用 + agent 原生 patch/diff + 文件版本冲突模型”;MNote 不提供 local-first 默认普通 Markdown 编辑工具,`mnote.doc.markdown_edit` 只在显式 cloud / remote / compat 且无法直接授权文件访问的边界中作为受控 fallback,`mnote.doc.apply_block_ops` 仅作为块级结构性辅助。CLI Main Lark Doc 只能作为 skill/workflow 纪律的有限参考,不作为当前编辑架构主参考。 -- `mnote.block.*` 降级为结构性辅助(拖拽排序、精确块删除等),不删除 -- 当前 `local_rule` planner(`direct_block_edit_operations`)是过渡实现,应继续退役 -- cloud / remote agent 无法直接访问本地文件时,不能静默回退到 `mnote.doc.markdown_edit`;必须通过显式 cloud source / remote fallback / compat 边界和权限设计承接 -- Markdown 既是 AI 编辑格式也是人类可读格式,不需要 XML 中间层 -- 流式 apply + suggest/review(参考 BlockNote AI 的 `StreamToolExecutor` + `suggestChanges`)仅作为 Phase C 设计冻结,当前不实施 - -对应设计稿: - -- `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`(历史执行记录;不再作为 active 入口) -- `/mnt/Data1T/mnote/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md` -- `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` -- `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`(历史回收稿;不再作为当前实现依据) - -### 8.5 Knowledge RAG / LightRAG - -目标: - -- 把资料库 ingestion、检索、引用和跨文档问答统一交给 LightRAG provider;OpenHub / native agent 只通过 MNote provider-neutral tool facade 调用。 -- MNote 只保留 source registry、allowed roots / 权限、FileTree 灯号、资料库设置 UI、provider status UI、citation/open-reference 映射和 agent tool facade。 -- LiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 作为历史迁移和 recycle 边界,不再作为默认 fallback。 - -当前进度(2026-07-03): - -- ✅ LightRAG 是当前默认知识库 provider,OpenHub / native agent 是 Page AI 执行与交互主链。 -- ✅ MNote `source registry` 记录 `workspaceId/rootUri/sourcePath/sourceHash/providerDocId/stale/deletedAt/status`,FileTree 灯号从 registry 映射为已索引 / 正在索引 / 失败 / 未索引;历史 `lightRagDocId` 只作为兼容字段。 -- ✅ WeKnora 仅保留为历史设计、参考实现或备用 provider 边界,不再作为新增主路径。 -- ✅ Agent 默认使用 `mnote.knowledge_rag.status/query/open_reference`,不再暴露旧 `mnote.evidence.*` 作为资料库检索主路径。 - -对应设计稿: - -- `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` -- `/mnt/Data1T/mnote/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md` - -### 8.6 定向 Bug Hunt 与质量基线 - -目标: - -- 围绕已知架构风险做定向排查,而非泛泛"找 bug" - -优先关注: - -- Page Aggregate 回流不一致 -- AI 块写入 conflict / stale revision -- File Tree `{title}.md` 与 page title 同步 -- tree stream resync / 双浏览器一致性 -- debug / compat / fallback 是否混入首屏主链 - -已有 smoke 体系(`scripts/` 目录): - -- `scripts/TESTING_REFERENCE.md`:当前 smoke 分类、默认基线和退役脚本说明 - -- `task110-page-title-single-truth-smoke.js`:标题全链路一致性 -- `task-page-aggregate-body-sync-smoke.js`:正文写入后 Page Aggregate 同步 -- `task-page-aggregate-options-sync-smoke.js`:页面设置写入后同步 -- `task-page-aggregate-refresh-persistence-smoke.js`:刷新持久化 -- `task123-rust-web-tree-live-stream-consumer-smoke.js`:tree realtime 消费 -- `task169-mindmap-realtime-smoke.js`:mindmap 隔离与持久化 -- `task179-tree-create-delete-no-reload-smoke.js`:树操作无刷新 - -## 9. 当前不该再用的旧口径 - -下面这些说法现在都不准确: - -- “默认文档页仍然是 `BlockNoteEditor`” -- “主编辑器还没切到页面内 Leptos island” -- “当前最优先是继续证明 `leptos-tiptap` 能不能跑” -- “当前最优先是先做树域 UI 重构” -- “Rust Web 还只是实验,没有接真实页面链路” -- “页面 AI 编辑必须经过 Hermes agent,没有快路径” -- “WeKnora 是当前默认知识库 provider。” -- “Hermes / Reasonix 是当前 Page AI 默认 agent runtime。” -- “页面 AI 编辑必须经过 MNote 专用工具才能改普通 Markdown” - -当前真正的卡点已经从“能不能跑”变成: - -> **管理员目录授权、文件版本冲突合并、agent diff 审计、本地轻量搜索 / 分享 / 同步如何产品化;知识库 RAG 主路径为 LightRAG + OpenHub tool facade,WeKnora 仅备用;Page Aggregate ClientState、projection / command / realtime 兼容链如何继续瘦身。** - -## 10. Control-plane 后端架构与运行配置 - -### 10.1 后端架构 - -当前 control-plane 存储后端已从单一 `rusqlite` 抽象为 `ControlPlaneStore` trait;mnote-web 运行时只允许 Turso/libSQL 系列后端: +## 1. 产品形态 ```text -ControlPlaneStore trait - ├── TursoControlPlaneStore mnote-web 运行时后端 - │ ├── libsql-local 默认本地运行模式,无云依赖 - │ ├── turso-remote 真实 Turso dev/prod DB - │ ├── turso-local-replica 本地 replica + remote sync - │ └── turso-synced libSQL synced database - └── SqliteControlPlaneStore 仅限 control-plane-admin 迁移/导出和 Rust 测试隔离 +MNote = VSCode 简化版工作区 + + tiptap Markdown 编辑器(leptos-tiptap island) + + Pi Rust Page AI + + LightRAG 知识库 + + simplemindmap / OnlyOffice 插件 + + Wolai 主题 Web 壳 + + Turso/libSQL 鉴权控制面 ``` -### 10.2 环境变量 +| 层 | 当前真源 / Owner | +|----|------------------| +| 工作区数据 | 本地 workspace folder;页面正文为 `.md` | +| 树 / 资源语义 | Rust kernel(`core-protocol` + `bridge-runtime`) | +| Web 入口 | `mnote-web`,公开端口 **3000** | +| 控制面 | Turso/libSQL:auth、membership、share、sync state、AI policy / Pi scope | +| Page AI | Pi Lab(`/api/page-ai/pi/*`),默认唯一产品级入口 | +| 知识库 | LightRAG + `mnote.knowledge_rag.*` facade | +| 编辑投影 | Page Aggregate(`mnote.page_aggregate.v1`);tiptap 为显示层,非对象真源 | + +一句话: + +> **本地文件夹是数据真相,Rust kernel 持有语义,Turso/libSQL 持有控制面,前端只消费稳定 projection。** + +## 2. 分层 + +### 2.1 Workspace / Storage + +- 默认 source:`local_folder` + 受管根:`/mnt/Data1T/Mnote_data/users//workspaces/my-space/` +- 管理员通过控制面授权可读写目录;普通用户不能自助全盘读写 +- 页面内上传默认写入 sibling assets(如 `README.assets/image.png`),正文存相对 Markdown 链接 +- 本地 `.md` / 附件 / mindmap / OnlyOffice **不**迁入 control-plane DB + +### 2.2 Kernel / Projection + +- `core-protocol`:树、资源、页面、AI access scope、page body write 等协议 +- `bridge-runtime` + `mnote-web`:LocalFS(及显式 cloud/compat source)→ 稳定 projection / command +- 前端只消费:`file_tree`、`page_tree`、`page_aggregate`、tree command result、少量 editor runtime payload + +树域三层: + +| 层 | 角色 | +|----|------| +| Resource Tree | kernel 对象组织真源(page / mindmap / attachment / onlyoffice / …) | +| File Tree | 主组织投影;页面正文行为 `{title}.md` | +| Page Tree | 导航投影;不持有结构真相 | + +正式命令面:`tree.*`(如 `tree.node.create` / `rename` / `tree.subtree.move`)。`documents.*` 仅为兼容层。 + +### 2.3 Web Shell / Editor + +- Owner:`mnote-web`(Rust SSR + browser runtime) +- 文档页默认编辑 host:页面内 `leptos-tiptap` island + - browser JS:`rust/crates/mnote-web/browser/*.js` + - island:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs` + - `layout.rs` / `tree.rs` / `web_shell.rs` 只做 SSR / bootstrap / route / asset 注入 +- 正文保存主入口:`page.body.write` / `/api/page-body/write`(带文件版本);`/api/documents/save` 为 compat +- Watcher:clean editor 自动刷新;dirty editor 进入冲突态,不静默覆盖 +- Realtime:WS 主链 `/api/realtime/ws` + SSE fallback `/api/tree/events`(snapshot / delta / resync) +- Mindmap:tree-first 的视图 / 挂件,非对象真源 +- OnlyOffice:独立页面型编辑器,经附件/资源跳转进入 + +### 2.4 Page Aggregate + +- 读取主链:Rust `mnote.page_aggregate.v1` snapshot(`/api/page-aggregate/:id`) +- 输出含 `blockDocument` / `blockProjectionVersion` / `projectionSource` +- 本地正文真相是 `.md`;aggregate 是投影,不是第二份正文库 +- 仍属过渡态:block projection 多从 markdown content 投影;客户端仍有 `PageAggregateClientState` reducer + +### 2.5 Page AI(Pi Rust) ```text -MNOTE_CONTROL_PLANE_BACKEND=libsql-local|turso-remote|turso-local-replica|turso-synced -MNOTE_TURSO_LOCAL_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db -MNOTE_TURSO_DATABASE_URL=libsql://... -MNOTE_TURSO_AUTH_TOKEN=... -MNOTE_TURSO_LOCAL_REPLICA_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db -MNOTE_TURSO_SYNCED_PATH=/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db -MNOTE_TURSO_SYNC_INTERVAL_MS=5000 +当前页 → 真实 .md + → AiAccessScope / allowed roots / selection + → Pi Rust Page AI(/api/page-ai/pi/*) + → agent 原生 patch/diff 写文件 + → watcher / BufferStore / refresh → Page Aggregate + tiptap ``` -### 10.3 默认策略 +- Pi session 元数据 / run / tool event 默认落 control-plane;Pi JSONL 仅为 runtime 工作副本 +- `mnote.doc.*` / `mnote.block.*` / `mnote.page.*`:cloud / remote / compat / 复杂结构辅助 +- `/api/page-ai/block-edit-workflow`:非 local-first 默认正文路径 +- 流式 apply + suggest/review:**Phase C 冻结,当前不实施** +- 遗留 ACP / Hermes / Reasonix / OpenCode:兼容面,默认主壳不注入;不得新增能力;退役须原子覆盖 sidebar、route、runtime、wrapper(`hermes_tools` 中 provider-neutral 契约按实际消费者保留) -- **普通开发/desktop/prod runtime**:默认 `libsql-local`,不再读取 `MNOTE_CONTROL_PLANE_DB_PATH`。 -- **云端验证**:显式使用 `turso-remote`、`turso-local-replica` 或 `turso-synced`,并从本机私有 env/secret manager 加载 URL/token。 -- **SQLite 边界**:`sqlite` 不能作为 mnote-web runtime backend;只允许 `control-plane-admin` 做迁移源、导出备份格式和 Rust 单测隔离。 +### 2.6 Knowledge RAG(LightRAG) -### 10.4 未迁移数据(第一阶段不迁) +- 资料 ingestion / query / citation / open-reference 主路径:LightRAG +- MNote 只维护 source registry、权限、FileTree 灯号、status UI、citation 映射、tool facade +- Agent 工具:`mnote.knowledge_rag.status` / `query` / `open_reference` +- 不把 provider storage 当用户正文真相;不复制图谱/chunk 为第二套索引 -- 本地 `.md` 页面正文:仍是 local-first 正文真相。 -- 本地附件、图片、mindmap、OnlyOffice 文件。 -- `.mnote/index/evidence.sqlite`:已从当前主线退役。 -- LightRAG 自身存储、WeKnora / OpenHub 自身存储。 -- OpenHub 自身 SQLite 会话库(第二阶段单独评估)。 +### 2.7 Control-plane -### 10.5 备份与恢复策略 +```text +ControlPlaneStore + ├── TursoControlPlaneStore mnote-web 唯一运行时 + │ ├── libsql-local 默认 + │ ├── turso-remote + │ ├── turso-local-replica + │ └── turso-synced + └── SqliteControlPlaneStore 仅 admin 迁移/导出与测试隔离 +``` -SQLite 不再作为运行时回滚后端。需要离线留档或人工核验时,用 `control-plane-admin export-target-to-sqlite` 导出备份;恢复方向应是从备份重新导入 libSQL/Turso 目标库,而不是让 mnote-web 改回 SQLite 运行。 +| 变量 | 用途 | 默认 | +|------|------|------| +| `MNOTE_CONTROL_PLANE_BACKEND` | 后端模式 | `libsql-local` | +| `MNOTE_TURSO_LOCAL_PATH` | local 文件 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db` | +| `MNOTE_TURSO_DATABASE_URL` | remote URL | — | +| `MNOTE_TURSO_AUTH_TOKEN` | remote token | — | +| `MNOTE_TURSO_LOCAL_REPLICA_PATH` | replica 路径 | `…/control-plane-replica.db` | +| `MNOTE_TURSO_SYNCED_PATH` | synced 路径 | `…/control-plane-synced.db` | +| `MNOTE_TURSO_SYNC_INTERVAL_MS` | sync 间隔 | 未设置 | -### 10.6 测试与脚本纪律 +- 只存元数据:users、sessions、workspaces、directory grants、share、sync state、AI policy/runtime、audit +- `sqlite` 不得作为 mnote-web runtime;启动链拒绝 +- 备份:`control-plane-admin export-target-to-sqlite` → 再导入 libSQL/Turso,不回退 SQLite 运行 -- 所有 smoke 和脚本**不得直接 `sqlite3` CLI 写 production control-plane DB**。 -- 需要 seed 数据时必须走 Rust API / 测试 seed endpoint / `scripts/lib/control-plane-dev-seed.js` 或 `scripts/lib/control-plane-test-env.js` helper。 -- 禁止通过临时文件覆盖 `MNOTE_CONTROL_PLANE_DB_PATH` 绕过真实 store。 -- 相关设计:`design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md` +### 2.8 代码入口 + +| 路径 | 职责 | +|------|------| +| `rust/crates/core-protocol/` | Kernel 类型与 projection 协议 | +| `rust/crates/bridge-runtime/` | query / command / traversal | +| `rust/crates/mnote-web/` | 3000 gateway、SSR shell、API、compat、realtime | +| `rust/crates/mnote-web/browser/` | Sidebar / FileTree / document adapter / Page AI host JS | +| `rust/spikes/leptos-tiptap-spike/` | 文档页默认编辑 island | +| `wolai-backend/app/` | 辅助后端 / 异步处理 | +| `src/components/onlyoffice/` | OnlyOffice 静态与插件资源 | + +`recycle/` 为历史回收区,不作为当前实现依据。 + +## 3. 当前收口缺口 + +阶段:local-first **MVP 后**。底座与主路径已立;重点是统一与产品化,不是扩 compat 或旧主链。 + +1. **WorkspacePath / ObjectIdentity** — 路径、identity、资源归属统一到 kernel contract +2. **DocumentBuffer / BufferStore** — runtime 已有;多 tab 仲裁与冲突 UI 体验收口 +3. **Page Aggregate** — 瘦身 compat join;退役 ClientState 混合 reducer,以 Rust snapshot 为单一运行时真相 +4. **tree command context** — 菜单 / 快捷键 / AI enablement 同一 context,勿各处硬编码 +5. **tree live cache** — watcher + WS/SSE 收进同一 cache,减补偿链 +6. **管理员目录授权 UI** — grant / access-policy 可管理 +7. **冲突合并 UI** — 接受磁盘 / 保留编辑器 / diff 合并 +8. **agent 写入审计** — changed files、diff、run id、actor 本地落盘,可同步控制面 +9. **本地轻量搜索** — workspace 全文 / 反链 / 标签,不依赖 cloud search +10. **分享与同步闭环** — share grant ≠ 本机 filesystem root;控制面不可用时不扩权限 +11. **插件资源模型** — mindmap / office 作为 Resource Tree 对象 +12. **遗留兼容面退役** — ACP/Hermes/Reasonix/OpenCode 原子删除;Convex 仅显式 cloud/compat + +执行稿:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` + +## 4. 设计索引(当前) + +| 主题 | 文档 | +|------|------| +| 总收口 | `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` | +| tree-first kernel | `design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` | +| local-first + control-plane | `design/02-convex-rust-long-term-architecture/done/2-2-*.md`、`process/2-9-turso-control-plane-cutover-v1.md` | +| Page Aggregate | `design/05-editor-mainline/reference/5-5-*.md`、`done/5-6-*.md` | +| AI 文件编辑 | `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` | +| LightRAG | `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` | +| tree command / realtime | `design/04-tree-domain/done/4-6-*.md`、`design/03-rust-web/done/3-3-*.md`、`3-14-*.md` | +| Resource / File / Page Tree | `design/04-tree-domain/done/4-24-*.md` | + +--- + +`CURRENT_ARCHITECTURE.md` 为兼容指针,内容以本文为准。 diff --git a/CURRENT_ARCHITECTURE.md b/CURRENT_ARCHITECTURE.md deleted file mode 100644 index bb7f4411..00000000 --- a/CURRENT_ARCHITECTURE.md +++ /dev/null @@ -1,188 +0,0 @@ -# 当前完整架构 - -> 更新时间:2026-06-28 -> -> 范围:`/mnt/Data1T/mnote` 当前可见实现、local-first MVP 后阶段主线口径、历史退役边界和后续功能缺口。 -> -> 2026-07-03 口径更新:当前 runtime 已收口为 OpenHub / native agent + LightRAG + Turso/libSQL。知识库与 Page AI 融合方向以 LightRAG + OpenHub 为准;WeKnora 仅保留为历史设计、参考实现或可选备用 provider 边界,Hermes / Reasonix 仅保留 legacy/debug/外部工作流边界。旧 `/api/hermes/*`、`hermes_tools/*`、ACP runtime 命名属于历史命名或迁移中的兼容层,不代表当前产品定义。 - -## 1. 当前产品形态 - -当前项目口径固定为: - -> **MNote = VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub + LightRAG AI + simplemindmap / office 插件 + Wolai 主题 Web 壳 + 鉴权控制面。** - -这意味着: - -- 初步 MVP 已建立,后续不是继续证明 Rust Web、local-first 或 tiptap island 是否成立,而是收紧底座、减少 compat、补齐产品化闭环。 -- 本地 workspace folder 是早期产品默认数据真相。 -- 本地 `.md` 文件是页面正文真相;Page Aggregate、tiptap state、AI context 都是投影或工作副本。 -- Rust kernel / projection / command 持有树、页面、资源和权限语义。 -- tiptap 是 Markdown 的前端显示与交互层,不是 agent 的主工作面。 -- OpenHub / native agent 默认应像 VSCode 中的 agent 一样,在授权目录白名单内用自身 patch / diff / 文件编辑能力修改文件。 -- LightRAG + OpenHub 是当前默认知识库与 Page AI 融合方向;MNote 负责 source registry、权限、FileTree 灯号、dashboard/status UI 和 citation/open-reference 映射。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界。 -- Turso/libSQL control-plane 是默认 auth、membership、share grants、sync state、AI policy、OpenHub scope / AI policy 控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,也不再是默认控制面,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。旧 ACP/Hermes runtime session 属于迁移兼容层。 - -上位设计已完成并迁入: - -- [2-2 local-first workspace 与 Convex 控制面降级方案](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md) -- [2-8 Convex 替换为 Rust SQLite 控制面](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md) -- [2-9 Turso / libSQL 控制面切换](/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md) - -## 2. 当前分层 - -### 2.1 Workspace / Storage - -- `local_folder` 是默认 source:`/mnt/Data1T/Mnote_data/users//workspaces/my-space/` 是受管“我的空间”默认根。 -- 管理员通过控制面授权用户可读写目录;普通用户不能自助获得全盘读写。 -- 上传到本地 Markdown 页面时,图片和附件默认写入 sibling assets,例如 `README.assets/image.png`,正文保存相对 Markdown 链接。 -- Convex source 仍可作为 cloud / compat / sync replica 或历史迁移源,但新增功能不能默认把 `documents.*`、`mediaAssets.*`、`aiSessions.*` 当主存储,也不能把根 Convex functions 重新作为默认控制面。 - -### 2.2 Rust Kernel / Projection - -- `core-protocol` 定义树、资源、页面、AI access scope、page body write 等协议语义。 -- `bridge-runtime` 和 `mnote-web` 负责把 LocalFS / Convex / future sync source 归一成稳定 projection 与 command。 -- 前端只消费 `file_tree`、`page_tree`、`page_aggregate`、tree command result 和少量 editor runtime payload。 - -### 2.3 Web Shell / Editor - -- `mnote-web` 是当前 3000 owner;Next / React 前端已退入 `recycle/`,只作为历史参考或 island bundle source。 -- 文档页默认编辑 host 是页面内 `leptos-tiptap` island。 -- 前端 runtime 已完成第一轮模块级拆分:浏览器 JS 功能默认从 `rust/crates/mnote-web/browser/*.js` 定位,tiptap island 行为默认从 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs` 定位;`layout.rs` / `tree.rs` / `web_shell.rs` 主要保留 SSR/bootstrap/route/asset 注入职责,不再作为大型 raw string 功能入口。 -- local source 正文保存主入口已收口到带文件版本的 `page.body.write` / `/api/page-body/write`;`/api/documents/save` 只作为 compat adapter。 -- 文件 watcher 发现 agent / 外部编辑器写入后,clean editor 自动刷新,dirty editor 进入冲突态,不静默覆盖。 - -### 2.4 AI Runtime - -- local-first 普通 Markdown 编辑主路径是: - -```text -当前页面定位到真实 .md 文件 - -> MNote 计算登录用户 allowed roots / current file / selection - -> OpenHub / native agent 在白名单目录内运行 - -> agent 使用自身 patch / diff / 文件编辑能力写文件 - -> MNote watcher / refresh 同步 Page Aggregate 与 tiptap -``` - -- `mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.doc.apply_block_ops`、`mnote.block.*` 和 `mnote.page.*` 保留为 cloud / remote agent / compat / 复杂结构辅助工具。 -- `/api/page-ai/block-edit-workflow` 不再作为 local-first 普通正文编辑主路径。 -- AI session 全文在 local source 下默认写入 `ai-sessions/private/*.jsonl` 或 `ai-sessions/shared//*.jsonl`;OpenHub scope / AI policy 默认落到 Turso/libSQL control-plane;旧 ACP/Hermes runtime session 仅为迁移兼容层;Convex 只作为显式 legacy/cloud metadata / audit / sync replica 边界。 - -### 2.5 Knowledge RAG / LightRAG - -- 知识库问答、资料 ingestion / query / citation / open-reference 当前主线为 LightRAG + OpenHub tool facade。 -- MNote 不复制 provider 图谱或 chunk,不把 provider storage 当用户正文真相;原始 source 仍属于 local-folder / Resource Tree。 -- MNote 维护 provider-neutral source registry,用于映射 `workspaceId/rootUri/sourcePath/sourceHash/providerDocId/stale/deletedAt/status`;历史 `lightRagDocId` 字段只作为兼容字段。 -- FileTree 的资料库灯号来自 source registry:绿灯已索引、黄灯正在索引或删除提交中、红灯失败 / 过期、无灯未索引或已移除。 -- LightRAG 相关 dashboard、status bridge、source registry 和 MCP bridge 是当前默认 provider 边界;WeKnora 仅作为备用 provider / 历史设计参考,不得被新增能力假设为默认存在。 -- Agent 只通过 `mnote.knowledge_rag.status/query/open_reference` 访问知识库;旧 LiteParse、OCR sidecar、`mnote.evidence.*` 与本地资料索引工具已退役为历史 / recycle 边界。 -### 2.6 Control-plane 后端 - -当前 control-plane 存储后端已抽象为 `ControlPlaneStore` trait;mnote-web 运行时只允许 Turso/libSQL 系列后端: - -- **TursoControlPlaneStore**(`libsql`):mnote-web 运行时后端,支持 local libSQL、remote URL + token、local replica + sync、synced database。 -- **SqliteControlPlaneStore**(`rusqlite`):仅用于 `control-plane-admin` 迁移/导出和 Rust 测试隔离,不再作为 mnote-web runtime fallback。 -- 启动时通过 `MNOTE_CONTROL_PLANE_BACKEND` 选择;默认 `libsql-local`,显式 `sqlite` 会被 mnote-web/dev-hot/desktop-hot/prod 启动链拒绝。 - -环境变量: - -| 变量 | 用途 | 默认值 | -|------|------|--------| -| `MNOTE_CONTROL_PLANE_BACKEND` | 后端模式(libsql-local/turso-remote/turso-local-replica/turso-synced) | `libsql-local` | -| `MNOTE_TURSO_LOCAL_PATH` | libSQL local 文件路径 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db` | -| `MNOTE_TURSO_DATABASE_URL` | Turso remote URL | — | -| `MNOTE_TURSO_AUTH_TOKEN` | Turso 认证 token | — | -| `MNOTE_TURSO_LOCAL_REPLICA_PATH` | Turso local replica 路径 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db` | -| `MNOTE_TURSO_SYNCED_PATH` | libSQL synced 本地路径 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db` | -| `MNOTE_TURSO_SYNC_INTERVAL_MS` | replica/synced 同步间隔 | 未设置 | - -第一阶段**只迁移 control-plane 元数据**(users、sessions、workspaces、directory grants、share links、 -sync state、AI policy/runtime、audit),本地 `.md` / 附件 / mindmap / OnlyOffice 文件不迁入 Turso。 -OpenHub 会话库后续作为单独阶段评估。 - - -## 3. 已完成 MVP 收口 - -- Local-first workspace 上位设计和 checklist 已完成,迁入 `design/02-convex-rust-long-term-architecture/done/2-2-*`。 -- 3000 下文档页、Sidebar/File Tree/Page Tree、tree command preferred path、tree realtime WS 主链 / SSE fallback、本地上传下载、AI 会话本地化与 VSCode-like agent 运行口径已建立。 -- `cargo fmt --check --all`、`local_folder`、legacy tool facade、local/shared AI session、local-first Convex guard、Convex export fixture、local asset upload smoke、external-change conflict smoke 均已作为 `2-2` 验收证据记录。 -- 本地上传链路已避免 Convex media asset,落盘相对 Markdown 链接。 -- 本地 AI 会话已区分 private / shared / cloud,并在 UI 上显示来源。 -- Convex guard 已阻止 active 路径重新引入未标注的 Convex documents / media / aiSessions 默认主存储口径,并拦截根 `convex/` functions 源码回流默认控制面。 -- Runtime 可维护收口已完成:`document-editor-adapter-runtime.js`、`sidebar-tree-runtime.js`、`tree-shell-runtime.js` 与 `leptos-tiptap-spike/src/lib.rs` 已拆到模块级,`mnote-web --lib`、`leptos-tiptap-spike --lib`、格式化、diff check 与 CodeGraph pending 0 已作为 `design/10-review/done/16-*` 的验收记录。 -- 当前 smoke 分类与默认基线以 `scripts/TESTING_REFERENCE.md` 为准;默认入口是 `3000 Rust SSR + leptos-tiptap island + local-first workspace + Turso/libSQL control-plane auth`。 - -## 4. MVP 后阶段功能缺口 - -1. **WorkspacePath / ObjectIdentity runtime 消费统一** - - 继续把本地路径、资源 identity、页面正文 `.md`、附件、mindmap、office 资源归属统一到 kernel / projection contract。 - - 防止 UI、compat route 或临时文件逻辑重新生成第二套资源真相。 - -2. **DocumentBuffer / BufferStore** - - 让 tiptap、文件 watcher、agent 写入、冲突检测和页面 tab 共用打开态与文件版本仲裁。 - - 避免 dirty editor、资源 tab、文件树点击之间各自维护状态。 - -3. **Page Aggregate compat 瘦身 / kernel-native projection** - - 继续退役 legacy `body.content` / compat join 边界。 - - local-first 正文真相保持为 `.md` 文件,Page Aggregate 只输出稳定投影。 - -4. **tree command context / context key** - - 菜单、快捷键、按钮、AI 写入能力的 enablement 判断应收口到同一套 command context。 - - 文件树和主编辑器右键菜单不能继续各自硬编码。 - -5. **tree live cache 统一** - - 把 local-folder watcher、projection refresh、WS/SSE snapshot/delta/resync 收进同一 live cache。 - - 减少 polling 或重复补偿链。 - -6. **Runtime owner 剩余专项** - - `design/03-rust-web/reference/3-23-sidebar-local-folder-resource-runtime-followup-v1.md` 记录 local folder / resource 编排拆分后的参考边界;新的执行项应另起 process 稿。 - - `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` 继续承接 Page AI sidebar runtime owner 细化。 - -7. **管理员目录授权 UI / API** - - 需要把 `access-policy.json` 和当前 auth actor、admin grant、read/write/share 权限做成可管理界面。 - - 管理员可授权任意 canonical 目录;普通用户只能访问自己的受管 my-space 或被授权目录。 - -8. **VSCode-like 冲突处理 UI** - - 当前已有 clean/dirty watcher 行为和冲突态 smoke。 - - 还需要做可用的“接受磁盘版本 / 保留当前版本 / 打开 diff 合并”交互。 - -9. **agent 写入审计** - - local-first agent 应回收 changed files、diff summary、tool/run id、actor、workspace root、permission level。 - - audit 默认本地落盘,同步开启时再上报控制面。 - -10. **本地轻量搜索与知识库 RAG 边界** - - 本地 `.md` workspace 需要独立索引:全文搜索、反链、页面引用、资源引用、标签。 - - 不能依赖 Convex search 才能搜索本地工作区。 - - 多资料 OCR / parse / index / RAG 不再由本地 evidence / LiteParse 承担,默认走 LightRAG provider;WeKnora 仅备用。 - -11. **分享与同步闭环** - - 需要把 share grants、shared workspace cache、shared AI session、只读/可写权限和冲突处理连成产品级闭环。 - - share grant 是产品层共享授权,local access grant 是本机目录授权;显式分享不能自动扩大本机 filesystem root 权限。 - - 控制面不可用时不得扩大本地缓存权限。 - -12. **插件资源模型** - - simplemindmap / office 应作为 Resource Tree 对象打开和保存。 - - Markdown 中只保留链接或嵌入引用,不把复杂对象强塞进普通正文块。 - -13. **旧 Convex 数据迁移产品化** - - 当前已有 fixture/offline 导出脚本。 - - 后续需要真实 Convex workspace 导出入口、迁移进度、冲突报告、回滚/备份策略。 - - 根 `convex/` functions 源码已软删除到 `recycle/20260522-convex-runtime-retirement/convex/`,只作为审计和迁移对照素材。 - -## 5. 已退役或降级口径 - -以下说法不再作为当前主线: - -- “Convex 是默认正文 / 附件 / AI 会话全文主存储。” -- “Convex 是默认 auth / membership / share / AI runtime 控制面。” -- “`mnote.doc.markdown_edit` 是 local-first 普通 Markdown 编辑唯一主路径。” -- “页面 AI 必须走 `/api/page-ai/block-edit-workflow` 或 `mnote.block.*` 才能改正文。” -- “`/api/documents/save` 是长期正文保存主入口。” -- “BlockNote 是当前默认编辑器或系统事实源。” -- “Next App Router / wolai-frontend 是 3000 主运行时。” -- “LiteParse / 本地 OCR sidecar / evidence search 仍是资料库 RAG 默认 fallback。” -- “MNote 需要为 LightRAG 结果再建一套 RAG 索引真相。” -- “WeKnora 是当前默认知识库 provider。” -- “Hermes / Reasonix 是当前 Page AI 默认 agent runtime。” - -历史文件如果需要保留这些说法,必须明确标注为 `[recycle]`、legacy、review snapshot 或 compat / cloud source 背景。 diff --git a/REASONIX.md b/REASONIX.md deleted file mode 100644 index afcb5e2b..00000000 --- a/REASONIX.md +++ /dev/null @@ -1,101 +0,0 @@ -# [LEGACY] MNOTE — Reasonix working knowledge - -> 2026-06-28 覆盖说明:Reasonix / Hermes 已退出当前 MNote Page AI 主线,Agent runtime 与知识库融合方向已切到 OpenHub / opencode / WeKnora。本文保留为历史工作知识和迁移对照,不作为当前实现依据;新增工作应优先参考 `AGENTS.md`、`CURRENT_ARCHITECTURE.md` 和 `design/07-ai/process/7-68*` / `7-69*`。 - -## Current Baseline - -- MNote 已进入 **local-first MVP 后阶段**:最小可用闭环已建立,后续重点是底座统一、compat 瘦身和产品化闭环。 -- 产品形态固定为:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub/opencode agent + WeKnora 知识库融合 + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 -- 本地 workspace folder 是默认数据真相;本地 `.md` 是页面正文真相;Page Aggregate、tiptap state、AI context 都是投影或工作副本。 -- Rust kernel / projection / command 持有树、页面、资源和权限语义;新增树规则不要散落到前端、Next route 或临时 compat 层。 -- Rust SQLite control-plane 已承接默认 auth、membership、share grants、sync state、AI policy、Page AI runtime session 等控制面;Convex / 服务端不再是默认正文、附件、AI 会话全文主存储,只保留历史迁移源、显式 cloud source、compat 和 sync replica 边界。 -- `mnote-web` 是当前 3000 owner;旧 Next / React / wolai-frontend 已移入 `recycle/`,默认不作为当前实现依据。 -- 前端 runtime 已完成第一轮模块级拆分;查浏览器 JS 功能默认先看 `rust/crates/mnote-web/browser/*.js`,查 tiptap island 行为默认先看 `rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`。不要把 `layout.rs` / `tree.rs` / `web_shell.rs` 的历史 raw string 当作当前符号定位入口。 -- Knowledge RAG 当前默认主线已切到 WeKnora / OpenHub 融合;LightRAG、LiteParse、旧 OCR sidecar、旧 evidence search / local-index agent tools 只保留为 legacy/debug/历史对照边界。MNote 负责 source registry、权限、FileTree 灯号、资料库 UI、dashboard/status 和 citation/open-reference。 - -## Stack - -- **Rust** — workspace of 11 crates, edition 2021, resolver 2, wasm32 target (`rust/rust-toolchain.toml`) -- **Axum 0.8** — HTTP/WebSocket server in `mnote-web` crate -- **Leptos 0.8.14** — SSR island rendering in `mnote-web` -- **Convex 1.x** — retired root functions source plus optional cloud/compat/sync replica service boundary, not the default control plane or document/media/session full-text store -- **Playwright** — browser smoke tests in `scripts/task-*.js` - -## Layout - -| Path | Contents | -|------|----------| -| `rust/crates/mnote-web/` | Rust web gateway (axum + Leptos SSR, `:3000`) | -| `rust/crates/mnote-web/browser/` | Browser runtime modules for sidebar, filetree, document adapter, tree shell, Page AI panel host | -| `rust/crates/mnote-web/src/routes/knowledge_rag.rs` | MNote LightRAG connector routes: status, ingest, query, delete, prune, open-reference | -| `rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs` | Hermes / Reasonix-facing knowledge RAG tool facade | -| `rust/crates/core-protocol/` | Kernel types, projection protocol, tree/graph terms | -| `rust/crates/bridge-runtime/` | Kernel query/command, projection bridge, source normalization | -| `rust/spikes/leptos-tiptap-spike/src/editor_runtime/` | Leptos-tiptap island runtime modules; use before editing root `lib.rs` | -| `rust/crates/mnote-cli/` | CLI tool (`sidebar dataset`, `page get/create/title/save/move`, `block insert/patch`) | -| `rust/crates/storage-convex-bridge/` | Convex compat / cloud source / sync-replica bridge for Rust | -| `recycle/20260522-convex-runtime-retirement/convex/` | Retired root Convex functions source retained for audit / migration reference | -| `recycle/20260522-convex-runtime-retirement/infra/convex/` | Retired self-hosted Convex backend compose kept only for historical cloud/compat/sync-replica reference | -| `infra/onlyoffice/` | Docker Compose for OnlyOffice | -| `scripts/` | Node.js dev/build scripts + Playwright smoke tests | -| `design/` | Architecture design documents organized by domain | -| `bugs/` | Bug tracking mirroring design domain structure | -| `recycle/` | Retired code (old frontend, deprecated design drafts) | - -## Commands - -- **`npm run desktop:hot`** — hot-reload dev start (Rust gateway + optional FastAPI backend + optional Celery) -- **`npm run dev:hot`** — same but with `cargo-watch` for Rust auto-recompile -- **`npm run desktop`** — production start -- **`cargo test -p `** — test a specific Rust crate -- **`cargo run -p mnote-web --bin mnote-web`** — run the web server binary directly -- **`codegraph sync .`** — refresh CodeGraph after normal code changes -- **`codegraph index . --force`** — rebuild CodeGraph after large refactors, ignore-rule changes, or stale index behavior - -## CodeGraph MCP - -- Reasonix has `codegraph=codegraph serve --mcp` configured in `/home/lix/.reasonix/config.json`. -- Prefer CodeGraph for structural code questions: symbol lookup, definitions, callers/callees, impact analysis, and focused cross-file context. -- Use `codegraph_search` for symbol names, `codegraph_callers` / `codegraph_callees` for call flow, `codegraph_impact` before risky edits, `codegraph_context` for focused task context, and `codegraph_files` / `codegraph_status` for index inspection. -- Use native search only for literal text, comments, log messages, or after a specific file is already identified. -- Keep the project index fresh in development: run `codegraph sync .` after code changes; use `codegraph index . --force` when the index looks stale or after broad restructuring. -- Runtime split is the current CodeGraph baseline: JS functions should be found in `browser/*.js` and editor runtime symbols in `editor_runtime/*.rs`. If search results point to `recycle/`, historical design docs, or old Rust raw strings, treat them as historical context unless the task explicitly targets legacy code. - -## Reasonix Worker Boundary - -- Reasonix is a leaf worker, not a main controller. -- Do not start nested Reasonix runners, subagents, extra worktrees, or rewrite task prompts from inside a Reasonix task. -- Coding tasks must use an explicitly assigned write scope and report every modified file. -- Read-only and browser tasks must leave `modified_files` empty and prove it with `git status --short` / `git diff --stat`. -- Browser verification must produce the artifact contract from `design/07-ai/process/7-35-reasonix-browser-test-contract-v1.md`: `result.json`, `final.md`, handoff files, screenshots, console/network evidence, and environment preconditions. -- Codex/Hermes main controller accepts Reasonix output only after checking handoff/result files, diff, validation evidence, and current repo status. Natural-language conclusions without artifacts are leads, not verification. - -## Reference-Code Comparison - -- Reference implementations live under `/mnt/Data1T/mnote/reference-code/`; prefer `/mnt/Data1T/mnote/reference-code/sidex-main` for VSCode workbench comparisons because it contains the fuller `src/vs/workbench` tree, while `reference-code/vscode` is a smaller auxiliary snapshot. -- Compare one feature slice at a time, such as explorer open target, editor tabs, resource lifecycle, drag-and-drop ordering, keybindings, or overlay placement. -- Fast workflow: check `codegraph_status` for both projects, locate reference entry points with `codegraph_search/context`, inspect callers/callees, inspect the matching MNote chain, then map the result onto Rust kernel / `mnote-web` / frontend host boundaries. -- Always classify findings as: reusable interaction/state model, reference-only implementation detail, or incompatible with MNote local-first / tree-first architecture. - -## Conventions - -- **Local-first default**: start from Rust `mnote-web`, LocalFS/local_folder projection, Page Aggregate, File Tree, Resource Tree, and SQLite control-plane auth/session/actor. Use Convex paths only for explicit cloud source, compat, sync-replica, migration, or rollback work. -- **Smoke test pattern**: standalone Playwright scripts under `scripts/task-*.js` using a shared harness (`ensureAuthenticated`, `createTempDocument`, `cleanupDocuments`). All use `"use strict"` and `require("playwright")` (`scripts/task110-page-title-single-truth-smoke.js:1-4`). -- **Smoke baseline**: use `scripts/TESTING_REFERENCE.md` for current/compat/manual/retired classification. Default browser baseline is `3000 Rust SSR + leptos-tiptap island + local-first workspace + SQLite control-plane auth`. -- **Rust workspace**: edition 2021, resolver "2", MIT license. All crates inherit workspace version (`rust/Cargo.toml:2-8`). -- **Convex boundary**: root `convex/` has been retired to `recycle/20260522-convex-runtime-retirement/convex/`; `infra/convex/` has been retired to `recycle/20260522-convex-runtime-retirement/infra/convex/`; do not recreate either as an active deploy source without a new architecture decision. `recycle/wolai-frontend/convex` is also historical. Keep Rust `transport/convex.rs` and `storage-convex-bridge` as explicit cloud/compat boundaries; do not mechanically delete them with the retired functions source. -- **AI editing path**: local-first Markdown editing should prefer `currentFile + selection + allowedRoots / aiAccessScope + agent native patch/diff + watcher sync`. `mnote.doc.*` / `mnote.block.*` are cloud, remote, compat, or complex-structure helpers. -- **Knowledge RAG path**: use `mnote.knowledge_rag.status/query/open_reference` and `/api/knowledge-rag/*`. Do not use `mnote.evidence.*`, LiteParse, or local OCR sidecar as the default retrieval/indexing path. Image sources enter LightRAG through a Markdown wrapper; MNote status bridge/backoff syncs LightRAG background completion back to the registry and FileTree. -- **Bug tracking**: bugs organized by design domain directory (e.g. `bugs/04-tree-domain/process/`), moved to `done/` when fixed (`bugs/README.md`). -- **Design docs**: organized by domain in `design/`, with `process/` (in-progress) and `done/` (completed) subdirs (`design/README.md`). - -## Watch out for - -- **write_file path rule**: paths are sandbox-relative; leading `/` is stripped. Use `path: "design/..."` not `/mnt/Data1T/mnote/design/...`. -- **`.aionrs/`, `.claw/`, `.codex/`, `.gemini/`** — session data from other AI tools, not project code. Don't read or edit. -- **`recycle/`** — retired code, not current implementation. Don't use as evidence for current behavior. -- **Convex availability**: local-folder / Rust projection smoke should not require Convex. Auth, share grants, ACP/Hermes runtime state, and AI policy default to SQLite control-plane; Convex is required only for tests that explicitly cover legacy cloud source, compat, sync replica, migration, or rollback behavior. -- **LightRAG boundary**: LightRAG storage is derived cache, not user source truth. MNote registry is the mapping authority for source path, doc id, stale/deleted state, FileTree lights, and citation routing. -- **MVP 后阶段优先级**: prefer WorkspacePath/ObjectIdentity runtime consumption, DocumentBuffer/BufferStore, Page Aggregate compat slimming, tree command context/context key, live cache unification, agent diff audit, conflict merge, local lightweight search, and share/sync productization. Knowledge RAG belongs to LightRAG, not a revived local evidence/LiteParse index. -- **Rust wasm32 target** required (`rust/rust-toolchain.toml`), needed for `tree-shell-runtime-wasm` crate. -- **`rust/target/`** is gitignored and large — `cargo build` runs from scratch if cache is missing. diff --git a/auth-console.txt b/auth-console.txt deleted file mode 100644 index 2d4ccc80..00000000 --- a/auth-console.txt +++ /dev/null @@ -1 +0,0 @@ -Total messages: 0 (Errors: 0, Warnings: 0) diff --git a/auth-network.txt b/auth-network.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/bugs/README.md b/bugs/README.md index 587ba176..8147b06d 100644 --- a/bugs/README.md +++ b/bugs/README.md @@ -1,10 +1,11 @@ # bugs 缺陷索引 -> 更新时间:2026-05-12 +> 更新时间:2026-07-19 > > 状态口径以当前仓库真实代码与真实验证结果为准: > - `[done]`:问题已修复,且已通过代码、smoke、浏览器或截图证据验证 > - `[process]`:问题已确认存在,仍在修复、验证或等待收口 +> - `[recycle]`:问题依赖已退役架构或已被后续主线覆盖,仅保留审计证据,统一归档到 `bugs/old/` ## 分类方式 @@ -17,7 +18,7 @@ - `05-editor-mainline/` - `06-mindmap/` - `07-ai/` -- 每个大类继续按 `process/` 与 `done/` 分层。 +- 每个大类继续按 `process/` 与 `done/` 分层;已退役或不再适用当前主线的记录移至 `bugs/old/<大类>/<状态>/`。 ## 归类原则 @@ -31,4 +32,5 @@ - 新确认的缺陷先进入对应大类的 `process/`。 - 缺陷在真实代码中修复并完成对应验证后,必须移动到对应大类的 `done/`。 - 不允许在 `process/` 与 `done/` 同时保留同一条缺陷。 +- `bugs/old/` 中的记录不得作为新增实现、测试或架构判断依据;标题必须标记 `[recycle]`。 - 若后续发现旧缺陷记录归类错误,应直接迁移到正确大类,而不是在错误大类继续追加。 diff --git a/bugs/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md b/bugs/old/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md similarity index 96% rename from bugs/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md rename to bugs/old/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md index 53c01048..ef8c12ca 100644 --- a/bugs/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md +++ b/bugs/old/03-rust-web/done/3-19-acp-reasonix-tool-call-missing-identity-fields-v1.md @@ -1,4 +1,4 @@ -# 3-19 [done][bug] Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段 v1 +# [recycle] 3-19 [done][bug] Reasonix ACP wrapper 调 mnote tool 缺少身份与幂等字段 v1 > 发现时间:2026-05-17 > diff --git a/bugs/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md b/bugs/old/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md similarity index 95% rename from bugs/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md rename to bugs/old/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md index 2cb697ed..c345aba0 100644 --- a/bugs/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md +++ b/bugs/old/03-rust-web/done/3-20-acp-request-permission-no-response-v1.md @@ -1,4 +1,4 @@ -# 3-20 [done][bug] ACP incoming request 只记录日志不响应 v1 +# [recycle] 3-20 [done][bug] ACP incoming request 只记录日志不响应 v1 > 发现时间:2026-05-17 > diff --git a/bugs/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md b/bugs/old/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md similarity index 94% rename from bugs/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md rename to bugs/old/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md index 355d114b..2815134f 100644 --- a/bugs/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md +++ b/bugs/old/03-rust-web/done/3-21-acp-run-payload-consumed-and-removed-v1.md @@ -1,4 +1,4 @@ -# 3-21 [done][bug] ACP run payload 被第一次 stream_events 消费后移除 v1 +# [recycle] 3-21 [done][bug] ACP run payload 被第一次 stream_events 消费后移除 v1 > 发现时间:2026-05-17 > diff --git a/bugs/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md b/bugs/old/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md similarity index 97% rename from bugs/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md rename to bugs/old/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md index b51088b1..7af5aae6 100644 --- a/bugs/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md +++ b/bugs/old/03-rust-web/done/3-22-acp-permission-auto-deny-ui-shows-approval-actions-v1.md @@ -1,4 +1,4 @@ -# 3-22 [done][bug] ACP permission 自动拒绝后 UI 仍展示审批动作 +# [recycle] 3-22 [done][bug] ACP permission 自动拒绝后 UI 仍展示审批动作 > 发现时间:2026-05-18 > diff --git a/bugs/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md b/bugs/old/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md similarity index 98% rename from bugs/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md rename to bugs/old/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md index 6d72b7b9..25221b16 100644 --- a/bugs/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md +++ b/bugs/old/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md @@ -1,4 +1,4 @@ -# MNote 浏览器 QA 实测发现 2026-07-08 +# [recycle] MNote 浏览器 QA 实测发现 2026-07-08 - **状态**:`process` - **来源**:Agent Board Pi Taskflow 实测(MNote 浏览器 QA → Bug 文档 20260708-130503) diff --git a/bugs/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md b/bugs/old/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md similarity index 95% rename from bugs/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md rename to bugs/old/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md index 4cb96918..16306813 100644 --- a/bugs/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md +++ b/bugs/old/07-ai/done/7-1-page-ai-provider-hermes-old-run-502-v1.md @@ -1,4 +1,4 @@ -# 7-1 [done] 页面 AI provider=hermes 仍命中旧 run route 导致 502 +# [recycle] 7-1 [done] 页面 AI provider=hermes 仍命中旧 run route 导致 502 ## 现象 diff --git a/bugs/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md b/bugs/old/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md similarity index 96% rename from bugs/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md rename to bugs/old/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md index a14ce092..762d5e3c 100644 --- a/bugs/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md +++ b/bugs/old/07-ai/done/7-19-hermes-tool-guidance-markdown-edit-contract-drift-v1.md @@ -1,4 +1,4 @@ -# 7-19 [done][bug] Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit v1 +# [recycle] 7-19 [done][bug] Hermes 工具指导仍优先 apply_block_ops 而非 markdown_edit v1 > 发现时间:2026-05-17 > diff --git a/bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md b/bugs/old/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md similarity index 96% rename from bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md rename to bugs/old/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md index 5699cfe9..4df26992 100644 --- a/bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md +++ b/bugs/old/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md @@ -1,4 +1,4 @@ -# 7-20 [done][bug] page_ai_workflow 绕过 Hermes tool executor / audit / toggle v1 +# [recycle] 7-20 [done][bug] page_ai_workflow 绕过 Hermes tool executor / audit / toggle v1 > 发现时间:2026-05-17 > diff --git a/bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md b/bugs/old/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md similarity index 98% rename from bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md rename to bugs/old/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md index 4873dfff..a85f68d6 100644 --- a/bugs/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md +++ b/bugs/old/07-ai/done/7-25-reasonix-block-edit-workflow-empty-block-ops-after-markdown-match-v1.md @@ -1,4 +1,4 @@ -# 7-25 [done][bug] ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops v1 +# [recycle] 7-25 [done][bug] ACP Reasonix 页面 AI block_edit_workflow 在 markdown 命中后生成空 block_ops v1 > 发现时间:2026-05-17 > diff --git a/bugs/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md b/bugs/old/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md similarity index 95% rename from bugs/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md rename to bugs/old/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md index 9b0fe0a5..726d005e 100644 --- a/bugs/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md +++ b/bugs/old/07-ai/done/7-31-page-ai-workflow-drops-read-summary-v1.md @@ -1,4 +1,4 @@ -# 7-31 [done][bug] 页面 AI block_edit_workflow 丢弃读取类回答摘要 v1 +# [recycle] 7-31 [done][bug] 页面 AI block_edit_workflow 丢弃读取类回答摘要 v1 > 发现时间:2026-05-18 > diff --git a/bugs/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md b/bugs/old/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md similarity index 95% rename from bugs/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md rename to bugs/old/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md index f5d6238f..0400556c 100644 --- a/bugs/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md +++ b/bugs/old/07-ai/done/7-34-page-ai-acp-runtime-default-reasonix-switch-v1.md @@ -1,4 +1,4 @@ -# 7-34 [done][bug] 页面 AI ACP Runtime 默认值回跳 Hermes v1 +# [recycle] 7-34 [done][bug] 页面 AI ACP Runtime 默认值回跳 Hermes v1 > 发现时间:2026-05-18 > diff --git a/bugs/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md b/bugs/old/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md similarity index 99% rename from bugs/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md rename to bugs/old/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md index 1a1bc5d6..45bde457 100644 --- a/bugs/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md +++ b/bugs/old/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md @@ -1,4 +1,4 @@ -# Browser QA needs-more-info +# [recycle] Browser QA needs-more-info - workflow_run_id: d7585a30-63cd-4201-a3c5-47e9e34b0773 - status: needs-more-info diff --git a/bugs/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md b/bugs/old/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md similarity index 99% rename from bugs/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md rename to bugs/old/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md index 98928da0..57864db7 100644 --- a/bugs/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md +++ b/bugs/old/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md @@ -1,4 +1,4 @@ -# Browser QA needs-more-info +# [recycle] Browser QA needs-more-info - workflow_run_id: fcaefa85-5cb9-45d6-b238-4d52cca03e97 - status: needs-more-info diff --git a/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md b/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md index 5f112c55..c08872d5 100644 --- a/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md +++ b/design/01-tree-first-graph-kernel/reference/1-1-tree-first-graph-kernel-checklist-v2.md @@ -2,7 +2,7 @@ > 更新时间:2026-04-16 > -> 当前状态:`reference`。本文保留 kernel 分阶段全景与历史状态口径;当前执行顺序以 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` 为准。 +> 当前状态:`reference`。本文保留 kernel 分阶段全景与历史状态口径;当前执行顺序以 `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` 为准。 > > 基于以下实际状态重写: > - 当前未提交代码 @@ -27,7 +27,7 @@ > 说明(2026-04-22): > 这份清单仍保留为 `Kernel Phase` 全景参考,但当前第一优先级已经转移到 > `Page Aggregate`、`tree command cutover`、`tree realtime event stream`。 -> 当前执行优先级请先看 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`。 +> 当前执行优先级请先看 `/mnt/Data1T/mnote/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。 --- diff --git a/design/01-tree-first-graph-kernel/reference/1-2-tree-first-graph-product-vision-progress-v1.md b/design/01-tree-first-graph-kernel/reference/1-2-tree-first-graph-product-vision-progress-v1.md index 09e30cee..1843d88f 100644 --- a/design/01-tree-first-graph-kernel/reference/1-2-tree-first-graph-product-vision-progress-v1.md +++ b/design/01-tree-first-graph-kernel/reference/1-2-tree-first-graph-product-vision-progress-v1.md @@ -8,7 +8,7 @@ > > 关联: > - `design/01-05-current-priority-overview.md` -> - `design/10-review/done/11-current-full-architecture-review-v1.md` +> - `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` > - `CURRENT_ARCHITECTURE.md` > - `design/11-wolai/help-center-reference.md` — Wolai 帮助中心功能对照基准 diff --git a/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md b/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md index 39d3ef51..f127d1db 100644 --- a/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md +++ b/design/01-tree-first-graph-kernel/reference/1-4-next-phase-execution-roadmap-v1.md @@ -4,13 +4,12 @@ > > 当前状态:`reference` > -> 归档说明(2026-05-22):本文保留下一阶段路线推导;当前可执行入口已收敛到 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`,不再从本文直接派发实现。 +> 归档说明(2026-07-19):本文保留下一阶段路线推导;当前可执行入口已收敛到 `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`,不再从本文直接派发实现。 > > 上位依据: > - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md` -> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` -> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/ARCHITECTURE.md`(`CURRENT_ARCHITECTURE.md` 为兼容指针) > > 目标:把当前已经推进到混合态的主线,收口成一条更清晰的下一阶段路线,避免“功能继续堆、底座继续散”的问题。 diff --git a/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md b/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md index 84cdc2e0..991b80da 100644 --- a/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md +++ b/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md @@ -2,10 +2,10 @@ > 更新时间:2026-04-22 > -> 当前状态:`reference`。本文是 tree-first graph kernel 的长期架构边界,不是当前可直接执行的 checklist;执行入口见 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`。 +> 当前状态:`reference`。本文是 tree-first graph kernel 的长期架构边界,不是当前可直接执行的 checklist;执行入口见 `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。 > > 当前优先级入口: -> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` > > 关联文档: > - `/mnt/Data1T/mnote/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md` diff --git a/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md b/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md index ee837a10..0b1add53 100644 --- a/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md +++ b/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md @@ -7,8 +7,7 @@ > 目标:在接受 Turso / libSQL Beta 与云服务风险的前提下,把 MNote 当前 Rust SQLite 控制面切换到 Turso / libSQL 后端,降低多个进程直接写同一个 SQLite 文件带来的损坏风险,并为后续云同步、审计、权限和 AI 控制面维护提供更稳的存储边界。 > > 上位依据: -> - `/mnt/Data1T/mnote/ARCHITECTURE.md` -> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/ARCHITECTURE.md`(`CURRENT_ARCHITECTURE.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/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` diff --git a/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md b/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md index c036d665..04058cbf 100644 --- a/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md +++ b/design/03-rust-web/reference/3-rust-web-long-term-architecture-v1.md @@ -2,7 +2,7 @@ > 更新时间:2026-05-09 > -> 当前状态:`reference`。本文保留 Rust Web 长期架构边界;当前实现队列以 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` 和对应 `done/` 证据为准。 +> 当前状态:`reference`。本文保留 Rust Web 长期架构边界;当前实现队列以 `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` 和对应 `done/` 证据为准。 > > 相关文档: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` diff --git a/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md b/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md index 080181c9..0885b51c 100644 --- a/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md +++ b/design/05-editor-mainline/reference/5-14-zed-lapce-vscode-reference-adoption-matrix-v1.md @@ -5,7 +5,7 @@ > 当前状态:`reference`。本文是参考实现采用矩阵,不是当前执行 checklist;具体实现任务应拆到对应 domain 的 process/done 文档。 > > 关联背景: -> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/ARCHITECTURE.md`(`CURRENT_ARCHITECTURE.md` 为兼容指针) > - `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` > - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` diff --git a/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md b/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md index 5c44dac7..bb9f2c13 100644 --- a/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md +++ b/design/07-ai/done/7-55-lightrag-docx-citation-rerank-alignment-v1.md @@ -4,14 +4,14 @@ > > 当前状态:`done` > -> 2026-07-03 口径回正:当前 runtime 已回到 OpenHub / native agent + LightRAG + Turso/libSQL。本文重新作为当前默认 LightRAG provider 的 DOCX 引用与定位合同基线;此前 `7-68 OpenHub + WeKnora + MNote Page AI 深度融合` 中将 WeKnora 设为默认 provider 的口径已标记 stale。 +> 2026-07-19 口径回正:当前 runtime 是 Pi Rust Page AI + LightRAG + Turso/libSQL。本文保留为当前默认 LightRAG provider 的 DOCX 引用与定位合同基线;WeKnora 仅为历史/备用边界。 > > Owner:07-ai / knowledge-rag / 03-rust-web / office-preview > > 关联: > - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` > - `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md` -> - `design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md` +> - `design/07-ai/reference/7-54-raganything-multimodal-retrieval-alignment-v1.md` > > 参考代码: > - LightRAG:`/mnt/Data1T/Mnote_data/lightrag/LightRAG` diff --git a/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md b/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md index cb9683b1..f8f08f68 100644 --- a/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md +++ b/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md @@ -1,18 +1,15 @@ # 7-18 [process] local-first agent 文件编辑控制面 v1 -> 2026-07-03 口径更新:本文中的 Hermes / Reasonix 主路径口径已退为历史参考。当前 Page AI / agent runtime 主线以 OpenHub / native agent + LightRAG + Turso/libSQL 为准;`7-68-openhub-weknora-mnote-deep-fusion-v1.md` 与 `7-69-weknora-native-embed-mnote-openhub-governance-v1.md` 的 WeKnora 默认 provider 口径已标记 stale。本文仅保留 local-first 文件授权、watcher 同步和冲突模型的参考价值。 +> 2026-07-18 口径更新:当前 Page AI / agent runtime 主线是 Pi Rust Page AI + LightRAG + Turso/libSQL。本文保留并推进 local-first 文件授权、watcher/BufferStore 同步、冲突模型与审计回收;Hermes / Reasonix / OpenHub 仅作为兼容与历史证据,不作为实现入口。 > 创建时间:2026-05-22 > > 当前状态:`PROCESS` > > 上位依据: -> - `/mnt/Data1T/mnote/ARCHITECTURE.md` -> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` -> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` +> - `/mnt/Data1T/mnote/ARCHITECTURE.md`(`CURRENT_ARCHITECTURE.md` 为兼容指针) +> - `/mnt/Data1T/mnote/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` > - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` -> - `/mnt/Data1T/mnote/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` > > 覆盖旧稿:`/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md` > @@ -31,7 +28,7 @@ MNote 在 local-first 下只提供: - agent runtime session、审计、changed files / diff 回收。 - watcher、BufferStore、Page Aggregate、ProseMirror 前台同步。 -Hermes / Reasonix 是已知 agent,使用自身文件 read / write / patch / diff 能力在授权目录内完成普通 `.md` 编辑。 +Pi Rust Page AI 使用自身文件 read / write / patch / diff 能力在授权目录内完成普通 `.md` 编辑。 ## 2. 明确退役项 @@ -67,7 +64,7 @@ Page / active editor -> resolve canonical .md file / resource file -> build AiAccessScope -> freeze allowed roots / allowed files / readonly / dirty / selection - -> start Hermes / Reasonix run with file refs and context + -> start Pi Rust Page AI run with file refs and context -> agent native patch / diff / write inside allowed roots -> MNote collects changed files / diff / audit -> watcher observes file changes @@ -287,7 +284,7 @@ Rust SQLite control-plane 是默认控制面,负责: ### Phase A:运行时输入与权限边界 -- [ ] 从文档页发起 Hermes / Reasonix run 时,request body 包含 `currentFile`、`allowedRoots`、`allowedFiles`、selection 和 dirty / readonly context。 +- [ ] 从文档页发起 Pi Rust Page AI run 时,request body 包含 `currentFile`、`allowedRoots`、`allowedFiles`、selection 和 dirty / readonly context。 - [ ] run body 包含 `targetPackage`,且 `allowedRoots` / `allowedFiles` 从 targetPackage 推导。 - [x] local-first 普通 Markdown 编辑 run 不包含 `mnote.doc.markdown_edit` 推荐工具。 - [ ] allowed roots 外文件写入被 agent runtime 或 MNote 审计层拒绝。 diff --git a/design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md b/design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md index 49ef986d..2bde8556 100644 --- a/design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md +++ b/design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md @@ -4,9 +4,19 @@ Owner:07-ai / mnote-web / control-plane 日期:2026-07-03 +## 0. 口径修订(2026-07-18) + +> **覆盖本稿原「保留 OpenHub 生产基线 / Pi 不作为默认入口」结论。** + +- 代码已默认 `enable_page_ai_pi_lab=true`(`MNOTE_PAGE_AI_PI_LAB`)。 +- OpenHub / Pi TS 已退役到 recycle 边界,不再是生产基线。 +- 当前 Page AI 默认主链为 **Pi Rust + LightRAG + Turso/libSQL control-plane**。 +- 本稿后续只承接 Pi Lab **去 spike / 产品化**(持久化、模块拆分、命名),不再要求「保留 OpenHub 主线」。 +- 上位收口:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。 + ## 1. 结论 -本稿确认一个新方向:**保留 OpenHub 作为当前生产基线,同时新开 Pi-first Page AI Lab,验证 Pi 是否能成为 MNote-native Page AI runtime**。 +本稿原确认方向为:**保留 OpenHub 作为生产基线,同时新开 Pi-first Page AI Lab**。该「双主线」结论已被 2026-07-18 口径修订覆盖;以下历史正文仅作背景,实施以 §0 与 checklist 21 为准。 这里的 Pi 指当前官方仓库 `earendil-works/pi` / 旧 `badlogic/pi-mono`,当前 npm 主包已迁到 `@earendil-works/*`。本轮重新拉取的源码证据位于: @@ -16,11 +26,11 @@ Owner:07-ai / mnote-web / control-plane Pi 不是 OpenHub 的直接替代品,但它是当前最值得优先 spike 的底层候选。原因是:Pi 已经具备 coding agent、RPC、session tree、tool event、extension / skill / package、provider auth、多模型和可扩展工具体系;这些比通用 UI 框架更贴近 MNote 的 local-first Page AI 目标。 -硬边界: +硬边界(2026-07-18 修订): -- 不替换当前 OpenHub 主线。 -- 不把 Pi Lab 作为默认 Page AI 入口。 -- 不把 Pi JSONL session 直接当作 MNote 长期会话真相。 +- ~~不替换当前 OpenHub 主线。~~ → OpenHub 已退役;Pi Rust 为默认 Page AI。 +- ~~不把 Pi Lab 作为默认 Page AI 入口。~~ → 默认开启;「Lab」仅为入口名。 +- 不把 Pi JSONL session 直接当作 MNote 长期会话真相(元数据走 control-plane)。 - 不绕过 MNote `AiAccessScope` / allowed roots / Turso control-plane。 - 不把第三方 Pi package 当成已验收能力;每个第三方包必须通过本地 smoke 后才能纳入。 diff --git a/design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md b/design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md index 2f7b1d5c..6211e43f 100644 --- a/design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md +++ b/design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md @@ -6,11 +6,11 @@ Owner:07-ai / mnote-web / control-plane / Page AI ## 1. 结论 -应该做 MNote 原生统一 AI 管理面板,但顺序不是先堆 UI,而是先把 control-plane 合同、持久化和 effective config API 做成唯一真相,再让 Pi Lab / OpenHub / LightRAG 消费同一套配置。 +应该做 MNote 原生统一 AI 管理面板,但顺序不是先堆 UI,而是先把 control-plane 合同、持久化和 effective config API 做成唯一真相,再让 **Pi Rust Page AI / LightRAG** 消费同一套配置。 文件夹授权必须只有一套:当前 `/admin/access-policy` 与 `/user/access-policy` 管理的 `directory_grants` 同时就是 MNote 工作区访问授权和 AI `allowed roots` 的事实源。AI 管理面板可以把它纳入同一个信息架构,但不能再维护第二套 AI-only allowed roots。 -OpenHub 的 admin 面板证明了正确产品结构:provider/model、skills、tools、MCP、目录权限、知识库、历史、用量、健康状态必须先有管理面,聊天 UI 只展示用户可用的有效配置。MNote 不能把 OpenHub admin 作为 MNote AI 的长期真相,因为 OpenHub 仍是独立默认 Page AI 主线,Pi Lab 是 MNote-native 新入口,二者后续可能任意退役。MNote AI 管理中心应落在 Turso/libSQL control-plane。 +OpenHub 的 admin 面板(历史)证明了正确产品结构:provider/model、skills、tools、MCP、目录权限、知识库、历史、用量、健康状态必须先有管理面,聊天 UI 只展示用户可用的有效配置。MNote 不能把 OpenHub admin 作为 MNote AI 的长期真相。**2026-07-18:OpenHub 已不再是默认 Page AI 主线;Pi Rust 为默认入口。** MNote AI 管理中心应落在 Turso/libSQL control-plane。上位收口见 checklist 21。 ## 2. 调研依据 @@ -41,7 +41,6 @@ OpenHub 的 admin 面板证明了正确产品结构:provider/model、skills、 源码: -- `earendil-works-pi-web-ui-0.75.3.tgz` - `/mnt/Data1T/tmp/pi-web-ui-shot/node_modules/@earendil-works/pi-web-ui/src/` - `/mnt/Data1T/tmp/pi-web-ui-shot/node_modules/@earendil-works/pi-agent-core/dist/` - `rust/crates/mnote-web/src/routes/page_ai_pi.rs` diff --git a/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md b/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md index 264ef7ad..c40b0a17 100644 --- a/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md +++ b/design/07-ai/process/7-75-knowledge-rag-ui-panel-optimization-v1.md @@ -9,7 +9,7 @@ > 范围:3000 主壳中的知识库 / RAG / OCR 状态面板、FileTree 知识库状态灯、当前页面附件索引入口、Page AI 对知识库状态的可见性。 > > 上位依据: -> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/ARCHITECTURE.md`(`CURRENT_ARCHITECTURE.md` 为兼容指针) > - `/mnt/Data1T/mnote/design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-52-lightrag-image-ocr-search-chain-hardening-v1.md` diff --git a/design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md b/design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md index 54fa6471..c5996375 100644 --- a/design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md +++ b/design/07-ai/reference/7-46-document-evidence-retrieval-kernel-v1.md @@ -17,7 +17,7 @@ > - `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` > - `/mnt/Data1T/mnote/design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md` > -> 参考项目:`reference-code/PageIndex`、`reference-code/BookRAG`、`reference-code/Kwipu`、`reference-code/ladybug`、`reference-code/seekdb` 均已单独建 CodeGraph;本文只吸收架构和合同,不把这些项目整体引入运行时。 +> 参考项目:`reference-code/PageIndex`、`reference-code/BookRAG`、`reference-code/Kwipu`、`reference-code/ladybug` 曾用于架构取证;`seekdb` 本地副本已在 2026-07 清理。本文只保留架构和合同结论,不把这些项目整体引入运行时。 ## 1. 第一结论 diff --git a/design/07-ai/reference/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md b/design/07-ai/reference/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md index 0c9ff90d..c4730bfe 100644 --- a/design/07-ai/reference/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md +++ b/design/07-ai/reference/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md @@ -9,11 +9,11 @@ > > Owner:07-ai / 03-rust-web / control-plane / 01-tree-first-graph-kernel > -> 参考项目:`/mnt/Data1T/mnote/reference-code/paperless-ngx` +> 参考项目:Paperless-ngx 本地副本已在 2026-07 清理;本文保留其已提炼的设计结论,不依赖本地源码。 > > 参考版本:`f56f29111` > -> CodeGraph:已单独建立,`712 files / 16,952 nodes / 35,407 edges` +> CodeGraph:历史取证时曾单独建立索引;本地参考副本已清理。 > > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` diff --git a/design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md b/design/07-ai/reference/7-54-raganything-multimodal-retrieval-alignment-v1.md similarity index 98% rename from design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md rename to design/07-ai/reference/7-54-raganything-multimodal-retrieval-alignment-v1.md index 56d4f333..4a992d32 100644 --- a/design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md +++ b/design/07-ai/reference/7-54-raganything-multimodal-retrieval-alignment-v1.md @@ -1,10 +1,10 @@ # 7-54 RAG-Anything 对照后的多模态检索设计 v1 -> 2026-06-28 覆盖说明:本文围绕 LightRAG 增强撰写,已被 WeKnora / OpenHub 知识库主线覆盖。保留为多模态检索参考材料,不作为当前实施依据。 +> 2026-07-19 归类说明:当前知识库主线是 LightRAG + Pi Rust Page AI。本文保留为多模态索引与失败模式的参考材料,不作为当前可直接执行的实施稿。 > 创建时间:2026-06-08 > -> 当前状态:`process` +> 当前状态:`reference` > > Owner:07-ai / 03-rust-web / knowledge-rag / search > diff --git a/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md b/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md index 7660142d..b5036b50 100644 --- a/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md +++ b/design/09-siyuan-reference/reference/9-siyuan-reference-boundary-and-adoption-v1.md @@ -2,7 +2,7 @@ > 更新时间:2026-05-11 > -> 当前状态:`reference`。本文只保留思源参考边界与可借鉴能力,不覆盖 `CURRENT_ARCHITECTURE.md` / `1-8` 当前口径,也不作为当前执行 checklist。 +> 当前状态:`reference`。本文只保留思源参考边界与可借鉴能力,不覆盖 `ARCHITECTURE.md` 当前口径,也不作为当前执行 checklist。 > > 上位依据: > - `/mnt/Data1T/mnote/ARCHITECTURE.md` diff --git a/design/10-review/README.md b/design/10-review/README.md index 71886eee..9f51fe9a 100644 --- a/design/10-review/README.md +++ b/design/10-review/README.md @@ -1,35 +1,35 @@ # 10-review 审查总览 -> 执行状态:`done/01` 到 `done/11` 均已归档;当前无活跃 10-review process 文档。 +> 执行状态:当前活跃 process 为 `21`;依赖 Convex、Hermes/Reasonix/ACP、OpenHub 或旧 tree shell 的审查已迁入 `design/old/10-review/`。 -当前活跃审查: +## 当前活跃审查 -- 暂无。 +- [21 MVP 后架构收口 Checklist](./process/21-mvp-post-architecture-closure-checklist-v1.md) -最新归档审查: +目标:在全面进入优化 / debug 之前,锁定唯一主链(Pi Rust + LightRAG + Turso control-plane)、消除文档漂移、完成 Pi Lab 去 spike、BufferStore 产品化、编辑器正式命名与 compat 冻结。 -- [Kernel 架构收口与下一阶段优先级 Review / Checklist](./done/08-kernel-architecture-next-priority-review-and-checklist.md) -- [当前 mnote 项目 AI / Page Aggregate 定向 Review](./done/10-current-mnote-ai-runtime-review-v1.md) -- [当前完整架构 Review](./done/11-current-full-architecture-review-v1.md) -- [页面 AI 快速块编辑 Runtime Review](./done/09-page-ai-fast-block-edit-runtime-review.md) +## 最新归档审查 + +- [20 Post-LightRAG / Agent Edit / Runtime hardening](./done/20-post-lightrag-runtime-hardening-checklist-v1.md) +- [16 mnote-web runtime module maintainability](./done/16-mnote-web-runtime-module-maintainability-checklist-v1.md) - [VSCode Explorer 文件树与垃圾箱闭环增量审查](./done/07-vscode-explorer-filetree-trash-gap-review.md) -上一轮最终执行入口: +## 历史审计(不得作为当前执行入口) -- [顺序执行清单与验收标准](./done/06-execution-checklist-and-acceptance.md) +- [顺序执行清单与验收标准](../old/10-review/done/06-execution-checklist-and-acceptance.md) 历史审查报告: -- [Rust Kernel / Web 实现偏差审查](./done/01-rust-kernel-web-review.md) -- [前端编辑器 / 树体验实现偏差审查](./done/02-frontend-editor-tree-review.md) -- [Convex / Realtime / Storage 实现偏差审查](./done/03-convex-realtime-storage-review.md) -- [次级域与设计治理实现偏差审查](./done/04-secondary-domains-and-design-governance-review.md) -- [树域 Resource Tree / File Tree / Page Tree 判断记录](./done/05-tree.md) +- [Rust Kernel / Web 实现偏差审查](../old/10-review/done/01-rust-kernel-web-review.md) +- [前端编辑器 / 树体验实现偏差审查](../old/10-review/done/02-frontend-editor-tree-review.md) +- [Convex / Realtime / Storage 实现偏差审查](../old/10-review/done/03-convex-realtime-storage-review.md) +- [次级域与设计治理实现偏差审查](../old/10-review/done/04-secondary-domains-and-design-governance-review.md) +- [树域 Resource Tree / File Tree / Page Tree 判断入口](../old/10-review/done/05-tree.md) ## 当前口径 -- `done/01` 到 `done/04` 是历史偏差审查快照;其中旧的“未完成 / 风险”条目已由 `done/06` 承接闭环,或转入对应主线设计文档继续跟踪。 -- `done/05-tree.md` 的 Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线已由 `design/04-tree-domain/done/4-24-*` 与 `design/05-editor-mainline/done/5-12-*` 承接完成。 -- 2026-05-15 起,Convex File Tree 的默认可见页面正文行由 `design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md` 覆盖为 `doc:` + `{title}.md`;`done/05-tree.md`、`4-24`、`5-12` 中的 `index.md` 表述仅作为历史 object identity / 兼容语义理解,不再作为默认 UI 可见模型继续派生新任务。 -- `done/06-execution-checklist-and-acceptance.md` 是上一轮 10-review 的最终验收依据;后续只作为防回归和口径核验材料。 +- `old/10-review/done/01` 到 `old/10-review/done/06` 是历史偏差审查快照,不得作为当前执行、验收或架构判断依据。 +- Resource Tree / File Tree / Page Tree、ObjectIdentity、mindmap 与 `index.md` 隔离主线以 `design/04-tree-domain/done/4-24-*` 与 `design/05-editor-mainline/done/5-12-*` 为准。 +- 历史 Convex File Tree、`index.md` 可见模型和 10-review 初轮执行清单均已归档。 - `done/07-vscode-explorer-filetree-trash-gap-review.md` 是 07-ai 开发前对 04-tree 文件树 / 页面树多选、默认删除进垃圾箱、垃圾箱恢复与永久删除、资源级 `tree.resource.*`、Convex purge 同步、VSCode Explorer 体验对标的增量审查归档。该审查 checklist 已闭合;后续仍应按文档中的“最低可用完成 / parity backlog”口径描述 VSCode Explorer 对标,不要把禁用态或待增强项说成完整 parity。 +- 2026-07-18 起,MVP 后架构收口以 `process/21-*` 为准:Page AI 默认主链为 **Pi Rust + LightRAG + Turso/libSQL**;OpenHub / Hermes / WeKnora 仅 legacy / recycle / 备用边界。 diff --git a/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md b/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md index a882b1e6..50db1764 100644 --- a/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md +++ b/design/10-review/done/20-post-lightrag-runtime-hardening-checklist-v1.md @@ -11,8 +11,8 @@ > - `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md` > - `design/07-ai/done/7-18-agent-edit-clean-dirty-smoke-checklist-v1.md` > - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` -> - `design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md` -> - `design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md` +> - `design/old/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md`(历史能力注册表) +> - `design/old/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md`(历史清理记录) > > 口径更新:LightRAG 7-50 已成为资料库问答主线,旧 LiteParse / evidence / local OCR 默认路径已退役。后续不再按“扩 RAG”理解,而是按“post-commit audit、边界硬化、减少 runtime 竞态和能力注册漂移”推进。 @@ -230,7 +230,7 @@ Checklist: 先修订设计: -- [x] 更新 `design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md`,把试点从 `mnote-local-index` 改为 `mnote-knowledge-rag`。 +- [x] 更新历史 capability registry,将试点从 `mnote-local-index` 改为 `mnote-knowledge-rag`;该注册表现已归档到 `design/old/07-ai/`。 - [x] 删除或改写 `mnote.evidence.*`、`mnote.index.*` 作为 active capability 示例的内容。 - [x] 明确旧 alias:`mnote-document-evidence` / `mnote-local-index` 只映射到 `mnote-knowledge-rag`,不恢复旧工具。 diff --git a/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md b/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md new file mode 100644 index 00000000..fa6743dd --- /dev/null +++ b/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md @@ -0,0 +1,270 @@ +# 21 MVP 后架构收口 Checklist v1 + +> 创建时间:2026-07-18 +> +> 状态:`process`(Phase 0–4 首轮收口已落地;默认只读已恢复;运行态回归与物理迁移为后续增量) +> +> 更新时间:2026-07-18 +> +> Owner:10-review(主控)/ 07-ai / 03-rust-web / 05-editor-mainline / 02-control-plane +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md`(`CURRENT_ARCHITECTURE.md` 为兼容指针) +> - `/mnt/Data1T/mnote/AGENTS.md` +> - `design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md` +> - `design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md` +> - `design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md` +> - 2026-07-18 架构选型 Review(会话结论) + +## 1. 结论 + +当前项目已进入 local-first MVP 后阶段。核心选型(tree-first kernel、本地 `.md` 正文真相、Turso/libSQL 控制面、mnote-web SSR + tiptap island、Pi Rust Page AI、LightRAG 知识库)**方向正确,不推倒重来**。 + +在全面进入「只做优化 / debug」之前,必须先完成一轮**架构收口**:锁定唯一主链、消除文档与代码漂移、把仍挂 spike 标签的生产路径产品化、冻结 compat 扩张。 + +本 checklist **只定义收口顺序、owner、验收与归档条件**;具体实现可继续拆到对应大类 `process/` 子稿。 + +## 2. 当前锁定主链(唯一口径) + +从本文件生效起,产品与工程默认口径为: + +```text +本地文件夹 / .md 正文真相 + → Rust kernel projection + tree.* / page.body.write + → mnote-web transport + BufferStore + watcher / realtime + → leptos-tiptap island(显示与交互投影,非正文真相) + → Pi Rust Page AI(MNOTE_PAGE_AI_PI_LAB 默认开启;allowed roots 内原生文件编辑) + → LightRAG(资料问答 / citation;非正文真相) + → Turso/libSQL control-plane(auth / grants / AI policy / runtime audit) +``` + +以下**不再是默认主链**,只保留 legacy / recycle / 显式 debug / cloud compat: + +- OpenHub / opencode 完整应用嵌入 +- Hermes / Reasonix / ACP 默认 Page AI runtime +- WeKnora 默认知识库 provider +- Convex 默认控制面或正文/附件/AI 会话主存储 +- BlockNote 默认编辑器 +- Next App Router 3000 主入口 +- `mnote.doc.markdown_edit` 作为 local-first 普通 Markdown 编辑默认入口 + +## 3. 硬边界(全程禁止) + +- 不把正文、附件、mindmap、Office 文件迁入 Turso/libSQL。 +- 不恢复 OpenHub / Hermes / WeKnora 为默认入口或默认 provider。 +- 不为「更正规」把 control-plane 换成 Postgres / 新云数据库。 +- 不把 tiptap / Page Aggregate / LightRAG storage 当成正文真相。 +- 不新增基于轮询的主链数据刷新。 +- 不新增 `documents.*` / hermes 兼容面的长期业务语义;compat 只删不增(除明确安全修复)。 +- 不把 Pi 原始 JSONL session 或 Pi web-ui IndexedDB 当成 MNote 长期会话真相。 +- 不绕过 `directory_grants` / `AiAccessScope` / allowed roots。 + +## 4. 执行顺序 + +必须按 Phase 0 → 4 顺序推进。Phase 0 未完成前,不开始大规模功能优化。 + +--- + +### Phase 0 — 口径锁定(P0,文档优先) + +Owner:10-review + 07-ai +目标:消除「OpenHub 生产基线 vs Pi 已默认启用」的漂移。 + +Checklist: + +- [x] 更新 `CURRENT_ARCHITECTURE.md` §2.4 / AI Runtime:Page AI 默认主链改为 **Pi Rust + LightRAG + Turso control-plane**;OpenHub 标注为已退役 / recycle。 +- [x] 更新 `ARCHITECTURE.md` 中仍写「OpenHub / native agent 默认」的段落,与上条对齐。 +- [x] 更新 `AGENTS.md`:Page AI local-first 主路径表述改为 Pi Rust;OpenHub 仅 legacy 边界。 +- [x] 在 `design/07-ai/process/7-69-page-ai-pi-first-lab-v1.md` 顶部增加 **口径修订(2026-07-18)**: + - 代码已默认 `enable_page_ai_pi_lab=true`; + - OpenHub 不再是生产基线; + - 本稿后续只承接 Pi Lab 去 spike / 产品化,不再要求「保留 OpenHub 主线」。 +- [x] 核对 `design/07-ai/process/7-71-*`:AI 管理中心仍以 Turso control-plane 为唯一配置真相;删除「OpenHub 仍是独立默认 Page AI 主线」的过时表述,或改为历史背景。 +- [x] 在本文件「验证记录」记一次文档 diff 摘要与审阅人。 + +验收: + +- [x] 验收:上述 rg 只在 `7-69` §0 历史修订/删除线上下文命中,active 主线口径无冲突。 +- [x] 普通用户 UI / 默认 env 描述中,不再引导切换到 OpenHub fallback。 + +--- + +### Phase 1 — Pi Lab 去 spike(P1,07-ai) + +Owner:07-ai / mnote-web / control-plane +关联:`7-71`、`page_ai_pi.rs` + +目标:Pi Rust 可称为生产 Page AI,而不再是 `0.1.0-pi-lab-spike` + 进程内 HashMap。 + +Checklist: + +- [x] **A. 会话持久化** + - [x] session 元数据经 `build_upsert_run_input` / `persist_upsert_run` 写入 control-plane `ai_runtime_runs`(`runtime_json` 携带 provider/model/page/allowedRoots)。 + - [x] 新增 `hydrate_session_from_control_plane`:`start` 命中未缓存 session 时先从 control-plane 恢复元数据到热缓存;`list_sessions` / `get_session_history` 已直接读 control-plane。 + - [x] 模块 doc 已注明:Pi JSONL 只作 runtime 工作副本,元数据权威源是 control-plane。 +- [x] **B. 审计与 receipt** + - [x] tool events / file patches 走 `append_ai_tool_event` / `append_ai_file_patch`,`artifact_diff` 已按 session 查询 control-plane。 + - [x] `PI_LAB_RECEIPT_STORE` 已注释为热缓存;jsonl 标注 `provider_neutral_jsonl_debug_fallback_v1`。 +- [x] **C. 模块拆分** + - [x] 拆为 `page_ai_pi/{mod.rs, constants.rs, runtime.rs}`;常量/schema 独立到 `constants.rs`。进一步细分(session/rpc/tools)作为后续增量,不阻塞归档。 + - [x] `mod.rs` 只做子模块声明与 `pub use`;route 装配仍在 `routes/mod.rs`。 +- [x] **D. 版本与命名** + - [x] `PI_LAB_VERSION` 由 `0.1.0-pi-lab-spike` 改为 `1.0.0`;schema 已是 `mnote.page_ai_pi.*.v1`。 + - [x] 默认 `MNOTE_PAGE_AI_PI_LAB=true`;文档口径已声明为生产主链。 +- [x] **E. 配置唯一真相** + - [x] `start` 已 `start_uses_ai_settings_for_model_skills_mcp_and_tools`;directory grants 走 `active_allowed_roots` + control-plane(单测覆盖)。剩余统一 AI 管理面 UI 仍由 7-71 承接。 + - [x] 目录写权限由 `tool_facade_enforces_directory_grant_read_and_write_permissions` 单测守卫。 + +验收: + +- [x] `cargo test -p mnote-web --lib page_ai_pi`:32 passed(2026-07-18)。 +- [x] 机制到位:`hydrate_session_from_control_plane` + control-plane list/history;真实重启浏览器 smoke 作为后续运行态验证项。 +- [x] 由既有 `task535/536/537/539` local-agent smoke 与 `tool_facade_enforces_directory_grant_*` 覆盖(7-18 归档证据)。 +- [x] 普通 UI 无 OpenHub fallback 入口。 +- [x] 根入口 `mod.rs` 12 行;巨石实现移入 `runtime.rs`(后续可继续按 session/rpc/tools 细分)。 + +归档条件: + +- 满足验收后,更新 `7-69` / `7-71` 状态;若 Pi 产品化完成,可将对应子 checklist 迁入 `design/07-ai/done/`。 + +--- + +### Phase 2 — BufferStore 产品化(P1,05-editor + 03-rust-web) + +Owner:05-editor-mainline / 03-rust-web +证据:`rust/crates/mnote-web/src/document_buffer_store.rs`、`core-protocol` `DocumentBuffer` + +目标:承认 BufferStore **已存在**,补齐产品闭环,并修正架构文档缺口描述。 + +Checklist: + +- [x] 更新 `CURRENT_ARCHITECTURE.md` §4:将「DocumentBuffer / BufferStore」从「功能缺口」改为「已有 runtime,待冲突 UI / 多 tab 仲裁收口」。 +- [x] 已盘点:`document-session-runtime.js`、`document-resource-tab-runtime.js`、`sidebar-page-ai-target-runtime.js` 均消费 `/api/documents/buffer-state`。 +- [x] 已存在:`document-conflict-panel-runtime.js` 提供 accept_disk / keep_editor / open_diff / merge;`web_shell.rs` 断言 `mnote-conflict-accept-disk`。 +- [x] `document_buffer_store.rs` `mark_external_modified` 已实现 Clean→ExternalModified、Dirty→Stale,并有单测。 +- [x] BufferStore 以 `BufferKey` + `file_version` 仲裁并 `rekey_local_folder_markdown`;多 tab 加固作为后续增量。 + +验收: + +- [x] 相关单测(`document_buffer_store` / `local_agent_audit`)在库测试中通过。 +- [x] 已有 `web_shell` 断言冲突面板控件;浏览器可见三选一由既有冲突面板 smoke 覆盖,新增回归作为后续项。 +- [x] `CURRENT_ARCHITECTURE.md` §4 项 2 已改为「runtime 已存在,产品化收口中」。 + +--- + +### Phase 3 — 编辑器正式化命名(P2,05-editor) + +Owner:05-editor-mainline / mnote-web +目标:生产编辑器不再住在 `spikes/`,事件名不再带 spike。 + +Checklist: + +- [x] 已建立 `rust/crates/mnote-tiptap-island/README.md`:产品名 + 迁移完成条件 + 过渡实现目录说明(薄 alias 计划)。 +- [x] island 现双发 `mnote:tiptap-island:*` 稳定事件 + 历史 `mnote:leptos-tiptap-spike:*`;`RUNTIME_NAME` 改为 `mnote-tiptap-island-runtime`。host 按事件名监听保持兼容。 +- [x] 物理 asset 名迁移列入 README 迁移完成条件(避免打断热更新/回归),本轮不做硬搬家。 +- [x] 提交前统一 `codegraph sync .`(见验证记录)。 + +验收: + +- [x] `leptos-tiptap-spike` `cargo check --lib` 通过(仅历史 warning);事件为附加式双发,不破坏 host。 +- [x] 新增 README 使用产品名 `mnote-tiptap-island`;spikes 路径仅作过渡实现说明。 + +说明:本 Phase **不换编辑器框架**,只做产品化收口。 + +--- + +### Phase 4 — Compat 冻结与瘦身(P2,持续) + +Owner:03-rust-web / 04-tree-domain / 05-editor +目标:优化阶段不再加厚兼容层。 + +Checklist: + +- [x] 公布 compat 冻结表(见下方「附录 A」),并记录 2026-07-18 基线调用点计数。 +- [x] 写入硬边界 §3 + 附录 A;作为后续 PR 检查口径。 +- [x] 每完成一条正式命令面替代,删除或降级对应 compat 调用点,并补负向测试「默认路径不走 compat」。 +- [x] 本地搜索 / 分享同步 / tree live cache 统一等产品缺口,继续走既有主线 design,不绕回 Convex。 + +验收: + +- [x] 基线已记录(附录 A);后续里程碑对照该基线做非增长检查。 +- [x] 现默认基线为 `3000 Rust SSR + leptos-tiptap island + local-first + Turso`,不依赖 OpenHub/Convex/BlockNote(TESTING_REFERENCE 现状)。 + +--- + +## 5. 明确不在本轮范围 + +- Phase C review session / streaming apply(设计冻结) +- 多设备完整云同步产品化(Turso remote 仅受控验证) +- EditorBlockDocument 原生落库替代 `.md` 真相(Markdown-first 可长期保持投影态) +- 替换 LightRAG / 自建第二套 RAG 索引 +- Wolai 全量视觉 parity(另走 wolai-aline) + +## 6. 与现有 process 稿的关系 + +| 稿件 | 关系 | +|------|------| +| `7-69` Pi-first Lab | Phase 0 修订口径后,承接 Phase 1 产品化 | +| `7-71` AI 管理控制面 | Phase 1-E / 配置唯一真相的实施主稿 | +| `7-18` local-first agent 文件编辑 | 行为合同保持;Pi 路径必须继续遵守 | +| `2-9` Turso cutover | 控制面边界不变;本轮不扩大迁移范围 | +| `CURRENT_ARCHITECTURE` §4 缺口 1–13 | Phase 2 起逐项改写状态;本 checklist 不替代其全部产品 backlog | + +## 附录 A — Compat 冻结表与基线(Phase 4) + +### A.1 允许扩展的正式面 + +- `tree.*`(tree command 正式命令面) +- `page.body.write` / `/api/page-body/write`(带文件版本的正文保存主入口) +- `/api/page-aggregate/*`(Page Aggregate 只读投影) +- Pi Rust Page AI:`/api/page-ai/pi/*` +- 知识库:`mnote.knowledge_rag.*` / `/api/knowledge-rag/*` + +### A.2 冻结(只读 / 待删 / 只做 adapter,禁止新增业务语义) + +- `/api/documents/save`、`/api/documents/title`(compat adapter) +- `documents.*` 命令面 +- `hermes` 命名 route / `hermes_tools/*` +- OpenHub / opencode proxy 与 fallback 入口 +- `mnote.doc.markdown_edit`(仅 cloud/remote/compat fallback) + +### A.3 2026-07-18 基线调用点计数(后续里程碑对照,不得增长) + +| 项 | 位置 | 计数 | +|----|------|------| +| `/api/documents/save` | `document-session-runtime.js` | 2 | +| `/api/documents/save` | `local-upload-runtime.js` | 1 | +| `/api/documents/save` | `document-editor-adapter-runtime.js` | 1 | +| `/api/documents/title` | `document-editor-adapter-runtime.js` | 2 | +| `hermes` 引用 | `routes/mod.rs` | 61 | +| Pi 正式 route | `routes/mod.rs`(`page_ai_pi::`) | 26 | + +> 检查方法:`rg -c "/api/documents/save" rust/crates/mnote-web/browser/*.js`、`rg -c "hermes" rust/crates/mnote-web/src/routes/mod.rs`。 + +## 7. 验证记录 + +| 日期 | Phase | 结果 | 证据 | +|------|-------|------|------| +| 2026-07-18 | Review | 架构选型 Review 完成;本 checklist 创建 | 会话 review;CodeGraph:`control-plane` / `BufferStore` / `page_ai_pi` | +| 2026-07-18 | Phase 0 | 文档口径统一为 Pi Rust + LightRAG + Turso | `CURRENT_ARCHITECTURE.md` / `ARCHITECTURE.md` / `AGENTS.md` / `7-69` §0 / `7-71` §1 diff | +| 2026-07-18 | Phase 1 | Pi 去 spike:version→1.0.0、模块拆分 `page_ai_pi/{mod,constants,runtime}`、`hydrate_session_from_control_plane` 恢复元数据 | `cargo test -p mnote-web --lib page_ai_pi`:32 passed | +| 2026-07-18 | Phase 2 | 确认 BufferStore/冲突面板已存在;文档缺口纠偏 | `document-conflict-panel-runtime.js`、`document_buffer_store.rs` 单测、`CURRENT_ARCHITECTURE` §4 | +| 2026-07-18 | Phase 3 | island 双发稳定事件 + `mnote-tiptap-island` 产品名/迁移计划 | `bridge_events.rs`、`runtime_bridge.rs`、`mnote-tiptap-island/README.md`;`cargo check --lib` 通过 | +| 2026-07-18 | Phase 4 | compat 冻结表 + 基线计数写入 | 附录 A | +| 2026-07-18 | 复核 | 恢复文档页默认只读:普通打开不能直接编辑,需点击「开始编辑」后进入编辑态 | `task804-document-read-mode-and-page-ref-conversion-smoke.js`;SSR title `readonly`、`data-document-user-readonly-mode="true"`、按钮初始 `开始编辑` | + +> 说明:以下项为「机制已就位、运行态回归待补」,已在对应勾选处标注,不作静默完成:Phase 1 真实重启浏览器 resume smoke;Phase 2 多 tab 仲裁加固与新增冲突 UI 回归;Phase 3 物理目录/asset 名搬家。这些不阻塞本轮归档,转入后续增量。 +> +> 回归已绿:复核后全量 `cargo test -p mnote-web --lib` 为 941 passed / 0 failed;`task804-document-read-mode-and-page-ref-conversion-smoke.js` 与 `task805-editor-block-menu-regression-static-smoke.js` 均通过。剩余 Phase 1 真实重启浏览器 resume smoke、Phase 2 多 tab 仲裁加固、Phase 3 物理目录/asset 名搬家仍按后续增量处理。 + +## 8. 归档条件 + +本文件迁入 `design/10-review/done/` 当且仅当: + +1. Phase 0 文档口径全部对齐; +2. Phase 1 验收通过(Pi 可称为生产 Page AI,不再是默认开启的 spike); +3. Phase 2 文档与冲突 UI 最低可用完成; +4. Phase 3 有明确迁移完成或有日期的兼容别名计划且主路径已切; +5. Phase 4 冻结表已写入并至少完成一轮 compat 负向检查。 + +未完成项不得静默勾选;若拆出子 checklist,在本文件保留链接与状态。 diff --git a/design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md b/design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md new file mode 100644 index 00000000..b761998a --- /dev/null +++ b/design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md @@ -0,0 +1,1025 @@ +# 12-1 [process] 密码箱(Password Vault)专用 CRUD 工作台整体设计 + Checklist v1 + +> 创建时间:2026-07-19 +> 修订时间:2026-07-19(+ 密文簿 cipher-book、folderPath 树分组、同站多账号;review 修订:path deny 合入门、软删合同、secret PATCH、刷新模型、PR 重排;re-review:resource/read 等表面、P0-S/E 门闩、hint 仅 reject;补全 live `/api/local-folder/*` 路由盘点表) +> 状态:`PROCESS` +> Owner:`12-vault`(跨 shell / AI / 安全的 **系统空间产品**;**不是** `04-tree-domain` 树节点能力,也非硬塞 `07-ai`) +> 建议 repo 落点:`design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md` +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/AGENTS.md` +> - `design/04-tree-domain/done/4-37-trash-as-modal-workbench-v1.md`(workbench 数据/API 组织参考;IA 不同:密码箱要独立 route) +> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`(`AiAccessScope` / allowed roots / 禁止裸读敏感路径) +> - `design/05-editor-mainline/done/5-34-local-markdown-attachment-ref-contract-v1.md`(附件引用语义 **启发**;vault 附件有独立 owner,见 §2.5) +> - `design/04-tree-domain/done/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md` +> - `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` + +--- + +## Overview + +MNote 需要一等公民的 **密码箱(Password Vault)**:按当前用户当前 workspace 解析的稳定系统目录,提供 **专用 CRUD 工作台页面**(完整列表 / 搜索 / 筛选 / 新建 / 编辑 / 删除 / 复制字段 / 显示隐藏密码),而不是普通 Markdown 笔记页,也不是仅靠 FileTree 打开自由 `.md`。 + +本设计将 vault 定为 local-first **系统空间**:`{workspaceRoot}/.mnote/vault/`,条目为带固定 frontmatter 模板的结构化 Markdown(`mnote.kind: credential`)。 + +**P0 实现 owner(对齐 trash 先例,避免「Kernel」过称)**: + +| 层 | P0 落点 | 说明 | +|----|---------|------| +| 存储 + HTTP API + SSR + browser | **`mnote-web`** | 与 local trash 相同:系统空间 API + projection JSON,**不是** `tree.*` 图节点 | +| 协议类型(可选) | `core-protocol` 最小 struct | 仅 DTO / schema 常量;**不**注册 bridge graph 节点 | +| bridge-runtime command 总线 | **P0 不做** | P1 再评估是否挂 `vault.*` 到 bridge;P0 以 `/api/vault/*` 为唯一写入口 | + +前端:`/vault` 独立 route + SSR workbench + browser runtime。 +**任何** 通用文件面(`/api/local-folder/files/open|stat`、**`/api/local-folder/resource/read|write`**、`assets/upload`、upload/write、Pi `mnote.local_file.*` / `resolve_file_path`)必须 **server-side deny** vault 路径——**在首次可写入 secret 的 PR merge 之前** 落地(见 §5.2、PR Plan);优先挂 **shared resolve choke**。 +AI 不得用通用 file 工具扫 vault,只能走分层 `mnote.vault.*`(P1)。LightRAG / local search 排除 `.mnote/vault/**`。一期不做密码学保险箱;安全分级 L0/L1/L2。 + +--- + +## Background & Motivation + +### 当前状态与痛点 + +1. **非正式密码笔记(用户工作区观察,非仓库事实)**:部分用户工作区存在 `个人/密码/` 下大量自由 Markdown 目录条目(用户环境曾观察到约 172 条量级),正文常含明文密码/token。无固定 schema、无统一入口、无 reveal 分级,且 **仍可被 FileTree / 本地搜索 / LightRAG 索引**——即使正式 vault 上线,在导入归档前该目录仍是泄露面(见 §7.4)。 +2. **左栏存在可改造入口**:`layout.rs` quick-actions 中 `inventory_2` 链到 `/files`。代码核实:`/files` **当前无注册 route**(死链),适合改造为「密码箱」固定入口。 +3. **垃圾箱模式可借鉴但 IA 不同**:4-37 是 **modal workbench**;用户明确要求密码箱是 **专门页面**。可复用 trash 的数据层:系统目录 ensure、index、SSR workbench、rootUri 鉴权。 +4. **AI / HTTP 安全缺口(已核实)**: + - `open_local_file` / `stat_local_file`(`/api/local-folder/files/open|stat`)在 workspace grant + path-escape 下可读 root 内 **任意** 相对路径,**无** `.mnote` / vault deny。 + - **`read_local_resource`**(`GET /api/local-folder/resource/read`)经 `resolve_local_file_open_path` 返回 JSON **`"text"` 全文**,同等无 vault deny——**比 open 更易被编辑器链路误用**。 + - `write_local_resource` / `assets/upload`(`targetRelativePath`)可写任意相对路径。 + - Pi `local_file_read` / `resolve_file_path`(`page_ai_pi/runtime.rs`)在 `allowed_roots` 内可读任意文件,**无** vault 排除。 + - 因此仅做 JS intercept **不够**;必须 server deny 清单 + **shared resolve choke**(§5.2)。 +5. **架构约束**:local-first、禁止 UI 拼第二套真相、浏览器主链禁止新增 `setInterval`、RAG 必须排除 vault。 + +### 代码锚点(已核实) + +| 锚点 | 路径 / 符号 | 含义 | +|------|-------------|------| +| 左栏 quick-actions | `ssr/pages/layout.rs` L145–151 | `inventory_2` → `/files`(死链);改造目标 | +| ensure | `local_folder_source.rs` `ensure_default_workspace_directories` | 仅 `.mnote/trash` | +| 树忽略 `.mnote` | `should_ignore_entry` | FileTree 不可见 vault | +| open/stat | `open_local_file` / `stat_local_file` | **必须加 vault deny**;stat **不**走 `resolve_local_open_path` | +| resource read/write | `read_local_resource` / `write_local_resource` | `GET/POST .../resource/read|write`;read 回 **`text` 全文** — deny 合入门 | +| assets upload | `upload_local_markdown_asset` / `write_local_folder_file_upload` | `POST .../assets/upload`;`targetRelativePath` 禁 vault | +| path resolve choke | `resolve_local_open_path` / `resolve_local_file_open_path` | open + resource 共用;**PR-S 优先挂此** | +| Watcher | `local_folder_watcher_registry.rs` | 路径分量含 `.mnote` 时不按普通树/索引消费;**不能**假设外部改 vault 会推产品事件 | +| Trash SSR | `gateway.rs` `trash_entry` | 系统空间页先例 | +| AiAccessScope | `core-protocol/src/ai.rs` | allowed_roots / allowed_file_paths | +| Pi tools | `page_ai_pi/runtime.rs` | `PiLabToolDefinition` + `resolve_file_path` / `local_file_read|patch` | +| RAG / search | `knowledge_rag.rs` / `local_search_index.rs` | 已跳过 `.mnote`;需 vault 单测锁死 | +| DirectoryGrant | `control-plane` `capabilities_json` | 现有 ad-hoc `"ai"`/`"share"`;vault.* **P1** 再落 grant 写入路径 | +| Attachment 启发 | design `5-34` | **页面 md** 附件合同;vault 附件为 **平行模型** | + +--- + +## Goals & Non-Goals + +### Goals + +1. 专用页面 `/vault`:完整 CRUD(列表 + 详情/表单),非 leptos-tiptap。 +2. 左栏固定入口「密码箱」:改造死链 `/files`,按当前用户 + workspace `rootUri` 解析。 +3. 固定 credential 模板:核心 `title`/`url`/`username`/`password`;可选 email/apikey/token/notes/tags;cookies 等走附件(P1+)。 +4. 存储:`{workspaceRoot}/.mnote/vault/`;frontmatter + `mnote.kind: credential`。 +5. **mnote-web 系统空间 API**:`vault.ensure|list|get|create|update|delete|restore|purge|reveal`(P0)与稳定 projection JSON;P1 增加 resolve / AI tools。 +6. 安全分级 L0/L1/L2;**通用文件面 path deny 为 secret 写入的硬前置**。 +7. RAG / local search / FileTree 默认不可见、不可索引 vault。 +8. 可选导入 `个人/密码/`;不静默覆盖;双轨期风险明示。 +9. AI:P0 仅 deny 通用读;P1 `mnote.vault.*`;P2 录入/自注册/cookies。 +10. Checklist + PR Plan 可直接开工。 + +### Non-Goals + +- 一期 **不做** at-rest 加密保险箱。 +- 不做跨 workspace 云端 vault 同步。 +- 不把 vault 做成 FileTree / Page Tree 节点或 `tree.*` 命令对象。 +- 不把 vault CRUD 塞进 tiptap / Page Aggregate。 +- 不做 OS 级自动填表扩展。 +- **P0 不做** 外部进程改 vault 文件后的 live FS 刷新(见 §4.2);禁止 setInterval 伪装。 +- 不把 vault 写入 LightRAG / evidence / 全局搜索 snippet。 +- **不** 声称导入前 `个人/密码/` 已与 vault 同等安全。 + +--- + +## Proposed Design + +### 1. 产品 IA + +#### 1.1 入口 + +```text +左栏 quick-actions + [搜索] [关系图] [导航] [帮助] [密码箱] [打开本地文件夹] [更多] + ↑ + 原 inventory_2 → /files(死链) + 改为 lock 图标 + 「密码箱」 + href="/vault?sourceKind=local_folder&rootUri=..." +``` + +- **主入口**:`layout.rs` 将 `/files`+`inventory_2` 改为 `/vault` + `lock`(默认;Open Q 可改 `key`),`class:active={current_nav == "vault"}`。 +- **href**:browser 注入当前 `sourceKind` + `rootUri`(读 `data-mnote-root-uri` 等)。无 root → empty state。 +- **bottom_entries**:`workspace_shell` 的 templates/`inventory_2` **不**改 vault。 +- **禁止**:通用 open 读 vault 文件(server deny);UI 若发现 vault 路径 → 引导 `/vault?id=` 或 403。 + +#### 1.2 路由 + +| Route | 方法 | 说明 | +|-------|------|------| +| `GET /vault` | SSR 全页 | 工作台;query:`sourceKind`、`rootUri`、可选 `id`、`import=1`、`status=active\|deleted` | +| `/files` | 兼容 | **302 → `/vault`**(保留 query 中 root 相关参数若可解析) | + +#### 1.3 页面布局 + +```text +┌─────────────────────────────────────────────────────────────┐ +│ PageLayout topbar: 密码箱 │ +│ ┌──────────┬──────────────────────────────────────────────┐ │ +│ │ Sidebar │ Vault Workbench │ │ +│ │ │ toolbar: 搜索 | 标签 | 新建 | 导入 | 刷新 │ │ +│ │ │ tabs: [活跃条目] [回收站] │ │ +│ │ │ list (40%) | detail/form (60%) │ │ +│ └──────────┴──────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +| 能力 | 行为 | +|------|------| +| 列表 | `vault.list` L0 projection | +| 搜索 | 客户端过滤 L0;不把 password 写入任何浏览器持久索引 | +| 新建/编辑 | create / get(mask) / update(§3.5 secret PATCH 语义) | +| 删除 | 确认 → **soft-delete**(P0,§2.6) | +| 回收站 tab | list `status=deleted`;restore / purge(P0) | +| 复制 / 显示密码 | copy 或 reveal;默认掩码;不写 localStorage | +| 刷新 | **仅**:(1) command 成功后 local patch / re-fetch;(2) 工具栏「刷新」按钮。**禁止** setInterval。**不**依赖现有 local folder watcher 推 vault 变更 | + +#### 1.4 P0 UI 验收(最小) + +- [ ] `?id=cred_…` deep-link:SSR/bootstrap 后选中对应行并加载详情。 +- [ ] 密码区默认掩码;未点「显示」前 DOM/HTML 无 password 明文。 +- [ ] dirty 表单点「取消」或切换条目:未保存则 confirm discard。 +- [ ] 必填:`title` 为空时保存 disabled + 行内错误;建议引导填写 url/username/password 至少一项。 +- [ ] 标签:逗号/Enter 添加 chip;删除 chip 即改 tags。 +- [ ] 回收站 tab 可见已删条目;restore 回活跃列表;purge 二次确认后磁盘无文件。 +- [ ] 窄屏:P0 可接受 list/detail 纵向堆叠(非阻塞);键盘快捷键非 P0。 + +Empty states:无 root / 0 条目(新建+导入提示,并 **警告** 源目录 `个人/密码/` 若存在仍可能被搜索/RAG 索引)/ 无权限。 + +--- + +### 2. 数据模型 + +#### 2.1 目录布局 + +```text +{workspaceRoot}/ + .mnote/ + vault/ # 系统 ensure + vault-index.json # L0 索引(含 status) + audit.jsonl # P0 唯一审计 sink(append-only) + entries/{credentialId}.md + attachments/{credentialId}/cookies-{stableId}.txt + trash/ + entries/ + attachments/ +``` + +**ensure**(扩展 `ensure_default_workspace_directories` + `vault.ensure`): + +```text +.mnote/trash +.mnote/vault +.mnote/vault/entries +.mnote/vault/attachments +.mnote/vault/trash +.mnote/vault/trash/entries +.mnote/vault/trash/attachments +``` + +访问 `/vault` 或任意 vault 写 API 前 ensure;失败返回明确错误。 + +#### 2.2 Credential schema(磁盘 + 类型) + +**磁盘 frontmatter 示例**(`relativePath` **一律相对 vault root**,禁止 `..`): + +```markdown +--- +mnote.kind: credential +schema: mnote.vault.credential.v1 +id: cred_01HZX... +title: GitHub — work +url: https://github.com/login +username: alice +# 示例为模板字面量(非真实密钥):Li@[A]s3cret +password: +passwordHint: "Li@[A]***" +email: alice@example.com +apikey: "" +token: "" +tags: [work, github] +fields: + otp_issuer: GitHub +attachments: + - refId: att_cookies_1 + kind: cookies + relativePath: attachments/cred_01HZX.../cookies-a1b2.txt + label: login-cookies +createdAt: 2026-07-19T08:00:00Z +updatedAt: 2026-07-19T09:30:00Z +createdBy: user_xxx +updatedBy: user_xxx +--- + +## Notes + +登录备注等自由文本(P0 按 L0 返回;勿把真密码只放 notes)。 +``` + +**磁盘 secret 字段**:`password` / `apikey` / `token` 为 **明文 string**(一期)。 + +**API 投影 `SecretFieldState`(wire)**: + +```ts +// 仅出现在 API 响应,永不作为磁盘存储类型 +type SecretFieldState = + | { state: "absent" } // 磁盘无值或空 + | { state: "masked" } // 有值但不返回明文 + | { state: "revealed"; value: string; expiresAt?: string }; // 仅 reveal/resolve 短时 +``` + +**存储侧 Rust(mnote-web vault_store,P0)**: + +```rust +pub struct VaultCredentialRecord { + pub id: String, + pub title: String, + pub url: Option, + pub username: Option, + pub password: Option, // 磁盘明文;序列化到 API 时变 SecretFieldState + pub password_hint: Option, // L0 可展示;禁止等于 password + pub email: Option, + pub apikey: Option, + pub token: Option, + pub tags: Vec, + /// P0: **仅非密钥元数据**(如 otp_issuer、environment)。 + /// 禁止放入 recovery_code / otp_secret 等;此类应使用 token/apikey 或后续 secretFields。 + pub fields: BTreeMap, + pub attachments: Vec, + pub notes_markdown: String, + pub status: VaultItemStatus, // active | deleted + pub created_at: String, + pub updated_at: String, + pub relative_path: String, // entries/{id}.md 或 trash/entries/{id}.md +} + +pub struct VaultAttachmentRef { + pub ref_id: String, + pub kind: String, // cookies | session | other + pub relative_path: String, // vault-root-relative;normalize 后不得含 .. + pub label: String, + pub content_type: Option, +} +``` + +**`fields` P0 合同**: + +- 仅 L0 非密钥元数据。 +- 服务端 create/update:若 key 命中拒绝列表(`password`/`token`/`apikey`/`secret`/`otp_secret`/`recovery` 等,大小写不敏感)→ `400 vault_fields_secret_forbidden`,提示改用标准 secret 字段。 +- 不提供对 `fields` 值的 reveal/resolve;避免旁路。 + +**`passwordHint`**: + +- L0;可进 list index。 +- create/update:若 `passwordHint` 与 `password` 在 trim 后相等 → **一律** `400 vault_hint_equals_secret`。 + **禁止** auto-clear / warn-and-continue 分支(P0 无第二路径)。 + +**必填**: + +- `title` 非空。 +- create:建议至少 `url|username|password` 之一;UI 引导,服务端可 warning 但仍允许仅 title(产品可收紧)。 + +#### 2.3 `vault-index.json` + +```json +{ + "schema": "mnote.vault.index.v1", + "revision": 12, + "updatedAt": "2026-07-19T09:30:00Z", + "entries": { + "cred_01HZX...": { + "id": "cred_01HZX...", + "title": "GitHub — work", + "url": "https://github.com/login", + "username": "alice", + "email": "alice@example.com", + "passwordHint": "Li@[A]***", + "tags": ["work", "github"], + "hasPassword": true, + "hasApikey": false, + "hasToken": false, + "attachmentCount": 1, + "relativePath": "entries/cred_01HZX....md", + "updatedAt": "2026-07-19T09:30:00Z", + "status": "active" + } + } +} +``` + +规则: + +- create/update/delete/restore/purge **原子**更新 index(temp + rename)。 +- index **永不**存 password/apikey/token 明文。 +- **Rebuild**(index 损坏或不一致时): + 1. 扫描 `entries/*.md` 与 `trash/entries/*.md`。 + 2. **L0-only allowlist 解析**:只抽取 id/title/url/username/email/passwordHint/tags/has* 标志/attachments 计数/status;**丢弃** password/apikey/token 键,不得进入 debug map。 + 3. 解析错误日志只含 `credentialId` / 相对路径 / error code,**禁止** frontmatter 原文或 secret 子串。 + 4. 运维:删除损坏 index 后调用 `POST /api/vault/ensure` 或 `POST /api/vault/reindex`(P0 可合并进 list 自动 rebuild)→ 重写 index。 + +#### 2.4 密码槽展示约定 + +| 层 | 约定 | +|----|------| +| 存储 | `password` 完整可用字符串(明文) | +| L0 提示 | 可选 `passwordHint`(不可逆示意,≠ 真密码) | +| UI | 默认 `••••••••`;reveal 全显;复制走 reveal 或 copy-after-reveal | +| 未来 | 加密字段延期 | + +页眉文案:**本地结构化密码箱,非加密保险箱**。 + +#### 2.5 附件(cookies / session)— P1 起完整交付 + +- **模型**:受 5-34 **启发**,但 owner 是 **credential**,不是页面 `.md`;不是同一 `AttachmentRef` 解析链。 +- **路径**:`relativePath` 仅 vault-root-relative,如 `attachments/{credentialId}/cookies-{id}.txt`;reject `..` 与绝对路径。 +- **下载**:`GET /api/vault/attachments/{credentialId}/{refId}?rootUri=`(需 `vault.view`);**禁止** 用 `/api/local-folder/files/open`。 +- **上传**:`POST /api/vault/attachments` 或 command `vault.attachment.put`(`vault.edit`)。 +- ensure 不要求预建每个 credential 子目录;put 时 create。 + +#### 2.6 删除策略 — **P0 冻结:软删除(方案 B)** + +| 操作 | P0 行为 | +|------|---------| +| `vault.delete` | 条目 md + 其 attachments **移动**到 `.mnote/vault/trash/...`;index `status: deleted` | +| `vault.list?status=active`(默认) | 仅 active | +| `vault.list?status=deleted` | 回收站 | +| `vault.restore` | 移回 `entries/` / `attachments/`;status active | +| `vault.purge` | 硬删 trash 内文件;index 移除键 | +| 全局 `.mnote/trash` | **不使用** | + +P0 UI 必须有回收站 tab(非「以后再说」)。 +**不做** P0 硬删作为默认 delete(避免与 K6 及回收体验不一致)。 + +--- + +### 3. mnote-web API(非 tree kernel) + +#### 3.1 命令/查询清单 + +| 名称 | HTTP | 权限(P0 矩阵 §5.1) | 阶段 | +|------|------|----------------------|------| +| `vault.ensure` | `POST /api/vault/ensure` | workspace 可读+可建目录 | P0 | +| `vault.reindex` | `POST /api/vault/reindex` | view | P0 | +| `vault.list` | `GET /api/vault/list` | view | P0 | +| `vault.get` | `GET /api/vault/items/{id}` | view | P0 | +| `vault.create` | `POST /api/vault/items` | edit | P0 | +| `vault.update` | `PATCH /api/vault/items/{id}` | edit | P0 | +| `vault.delete` | `DELETE /api/vault/items/{id}` | edit | P0 soft | +| `vault.restore` | `POST /api/vault/items/{id}/restore` | edit | P0 | +| `vault.purge` | `POST /api/vault/items/{id}/purge` | edit | P0 | +| `vault.reveal` | `POST /api/vault/items/{id}/reveal` | view + 登录会话 | P0 | +| `vault.cipherBook.list` | `GET /api/vault/cipher-book` | view | P0 | +| `vault.cipherBook.put` | `PUT /api/vault/cipher-book/{key}` | edit | P0 | +| `vault.cipherBook.delete` | `DELETE /api/vault/cipher-book/{key}` | edit | P0 | +| `vault.cipherBook.reveal` | `POST /api/vault/cipher-book/{key}/reveal` | view + 登录会话 | P0 | +| `vault.resolve` | `POST /api/vault/items/{id}/resolve` | resolve capability | P1 | +| `vault.import.preview/apply` | `POST /api/vault/import/*` | edit | M | +| `vault.attachment.*` | 见下 | view/edit | P1–P2 | +| `vault.export` | — | export | P2 | + +#### 3.1a 密文簿 / 分组 / 同站多账号(P0 产品补充) + +1. **密文簿(cipher-book)** + - 磁盘:`{workspaceRoot}/.mnote/vault/cipher-book.json`(schema `mnote.vault.cipherBook.v1`)。 + - 密码 / apikey / token 可写模板,如 `Li@[A]s3cret`;`[Key]` 中 Key 为 `[A-Za-z0-9_]{1,32}`。 + - **存储**:条目里保留模板原文;**reveal** 时展开为明文 `value`,并可选返回 `template` / `usedCipherKeys` / `missingCipherKeys`。 + - L0 list 仅返回 keys + `hasValue`,**永不**投影片段明文。 + +2. **同站多账号** + - **不**对 `url` 做唯一约束;同一 URL 可对应多条 credential(不同 username)。 + - UI:详情提供「同站新账号」预填 url/folderPath;列表 meta 展示 username 区分。 + +3. **分组折叠(folderPath)** + - 逻辑字段 `folderPath`(如 `个人/银行/招商`),**不是** vault 下真实子目录。 + - 左栏按路径多级折叠;折叠状态可存 sessionStorage。 + - 条目文件仍平铺在 `entries/{id}.md`。 + - **表单 UX(已落地)**:下拉选择已有 `folderPath` + 文本直接输入新路径(无弹窗、无空组、无「在此新建」);组随条目出现。 + +4. **同站 URL 折叠** + - 同一 `folderPath` 节点下,**相同非空 url 且 ≥2 条** 时,再包一层可折叠「站点组」。 + - 单条 URL 不额外包组;无 url 条目平铺。 + +5. **共享到 AI 密码本** + - `POST /api/vault/items/{id}/share-to-ai`:把条目(含 secret 模板)**复制/更新**到目标 actor 的 managed 默认工作区 vault。 + - 默认目标 actor:`mnote-e2e`(env `MNOTE_AI_VAULT_ACTOR` 可改)。 + - 副本加 tag `ai-shared`;`folderPath` 默认 `from-{sourceActor}`。 + - **双向链接(磁盘 frontmatter)**:源 `sharedToAi{targetActorId,targetId,sharedAt,lastSyncedAt}`;副本 `sharedFrom{sourceActorId,sourceId,sharedAt}`。 + - 再次共享同一源:**更新**已有 AI 副本(不重复建条);按钮文案变为「同步到 AI 副本」。 + - **编辑自动同步**:`PATCH` 源条目默认 `syncAiShare=true`,若有 `sharedToAi` 则推送到 AI 副本(密文 key 同步规则同 share)。 + - **AI 密码本识别**:list 返回 `isAiVault` / `vaultRole`;AI 本顶栏标识「AI 密码本」,**隐藏**「共享到 AI」;副本展示「AI 副本」横幅/标签。 + - 用户本上已共享条目:标题旁轻量 chip「已共享」;按钮「共享 AI / 同步 AI」+「撤销共享」。 + - **撤销共享**:`POST /api/vault/items/{id}/unshare-from-ai`:清除源 `sharedToAi`,AI 副本 soft-delete(进回收站);密文 key 不删。幂等。 + - **密文簿同步(默认开)**:`syncCipherKeys` 默认 `true`。 + - 密文簿 **按 workspace 隔离**;单本内 key 唯一。 + - 编辑 secret 明文;查看态掩码;空字段默认不展示。 + - Skill:`skills/mnote-vault` + `CAPABILITY_PACKS` 注册 id `mnote-vault`(hermes/reasonix 可发现)。 + +#### 3.2 HTTP 映射(完整 P0 + 附件骨架) + +```text +POST /api/vault/ensure +POST /api/vault/reindex +GET /api/vault/list?rootUri=&sourceKind=local_folder&status=active|deleted +GET /api/vault/items/{id}?rootUri= +POST /api/vault/items +PATCH /api/vault/items/{id} +DELETE /api/vault/items/{id} +POST /api/vault/items/{id}/restore +POST /api/vault/items/{id}/purge +POST /api/vault/items/{id}/reveal # body: { "field": "password"|"apikey"|"token" }; expands [Key] +POST /api/vault/items/{id}/share-to-ai # body: { rootUri, targetActorId? } → AI vault clone +POST /api/vault/items/{id}/unshare-from-ai # body: { rootUri, targetActorId? } → clear link + soft-delete AI copy +GET /api/vault/cipher-book?rootUri= +PUT /api/vault/cipher-book/{key} # body: { rootUri, value } +DELETE /api/vault/cipher-book/{key}?rootUri= +POST /api/vault/cipher-book/{key}/reveal +POST /api/vault/items/{id}/resolve # P1 +POST /api/vault/import/preview +POST /api/vault/import/apply +GET /api/vault/attachments/{credentialId}/{refId} # P1 +POST /api/vault/attachments # P1 +DELETE /api/vault/attachments/{credentialId}/{refId} # P1 +``` + +统一响应壳:`{ ok, requestId, traceId, owner: "mnote-web", result }`。 +错误码:`vault_root_required` / `vault_forbidden` / `vault_item_not_found` / `vault_path_denied` / `vault_secret_denied` / `vault_fields_secret_forbidden` / `vault_hint_equals_secret` / `vault_revision_conflict` / `vault_mask_sentinel_rejected`。 + +#### 3.3 Projection + +`mnote.vault.list.v1` / `mnote.vault.item.v1`:secret 字段仅为 `SecretFieldState`(默认 masked/absent)。 +`mnote.vault.secret.v1`:reveal/resolve 用;**access log 不得记录 `value`**。 + +#### 3.4 实现落点(owner crate) + +| 层 | 落点 | +|----|------| +| HTTP + storage | `mnote-web/src/routes/vault.rs` + `vault_store.rs`(或同模块) | +| 共享 path deny | `mnote-web` 内 `vault_path.rs`:`is_vault_sensitive_relative_path(rel) -> bool` | +| 调用方 | open/stat/upload/write、`page_ai_pi` 的 `resolve_file_path` / local_file_read|patch | +| SSR | `ssr/pages/vault.rs` + gateway/page route | +| Browser | `browser/vault-workbench-runtime.js`;在 `layout.rs` 与其它 workbench 一样加 **script 标签**,并由 `mnote-ui-runtime.js` import 注册(与 filetree/trash 模块同一模式) | +| core-protocol | **可选** P0:仅 schema 字符串常量;不强制 | +| bridge-runtime | **P0 不注册** | + +原则:浏览器 **禁止** 直接写 vault 路径;唯一 writer = vault HTTP handlers。 + +#### 3.5 Secret 字段 create / update 语义(**强制**) + +**标准 secret 键**:`password` | `apikey` | `token`。 + +**PATCH `vault.update` body(camelCase)**: + +| 客户端传入 | 服务端行为 | +|------------|------------| +| 字段 **缺省**(JSON 键不存在) | **保持**磁盘原 secret | +| `"password": null` 或 `{ "password": { "clear": true } }` | **清空** secret | +| 顶层 `"password": ""`(空字符串)或 `{ "set": "" }` | **清空** secret(与 clear 同义;**禁止**把空串当「未改」) | +| `"password": { "set": "" }` | 设为新明文(内部空格保留;仅拒纯 mask 哨兵) | +| `"password": "••••"` / `"****"` / `"•"+` / `"••••••••"` 等掩码哨兵 | **`400 vault_mask_sentinel_rejected`**,不写盘 | +| 顶层 `"password": "plaintext"`(非空裸字符串) | **允许**,语义同 `{ set }`(便于表单);仍拒绝哨兵 | + +**仅改 title/tags 等 L0 字段**:body 不含 secret 键 → secrets **原样保留**。单测:**update title only keeps password**。 + +**create**: + +- 可用裸字符串或 `{ set }`。 +- 缺省 secret → absent。 +- 禁止哨兵。 + +**import apply**:写入真实解析值;不经 mask 状态机。 + +**乐观并发(P0 建议)**: + +- index / item 带 `revision`;update 可带 `baseRevision`;冲突 → `409 vault_revision_conflict`(两 tab 编辑)。P0 最低:last-write-wins + UI 保存后 re-fetch;checklist 优先有 revision 更好。 + +--- + +### 4. 前端落点 + +#### 4.1 模块地图 + +| 层 | 文件 | 职责 | +|----|------|------| +| SSR | `ssr/pages/vault.rs` | `VaultPage`,`current_nav="vault"` | +| Page route | `gateway` 或 `routes/vault_page.rs` | auth + rootUri + bootstrap list(无 secret) | +| API | `routes/vault.rs` | CRUD + reveal + soft-delete | +| Path deny | `vault_path.rs` + 挂入 open/stat/… | §5.2 | +| Styles | `ssr/styles` 或 vault CSS | workbench | +| Runtime | `browser/vault-workbench-runtime.js` | CRUD UI | +| 入口 | `layout.rs` | 密码箱链接 + script 注入 | +| 拦截 | resource-open **附加** UX | **不能**替代 server deny | + +#### 4.2 刷新模型(修正原 watcher 误称) + +| 来源 | P0 | +|------|-----| +| vault API 成功 | 本地 patch list 或 re-fetch `vault.list` | +| 工具栏「刷新」 | re-fetch | +| 现有 local folder watcher / WS tree events | **不**用于 vault UI;`.mnote` 变更本就不按产品树消费 | +| 外部编辑器改 vault 文件 | **非目标**;用户按「刷新」。未来若做 `vault.changed`,只由 vault command 广播,**永不**把 secret body 送进 search 索引管线 | +| setInterval | **禁止** | + +#### 4.3 禁止 + +- tiptap 表单、localStorage 存 password、UI 第二套真源(draft 仅编辑态)。 + +--- + +### 5. 安全分级与硬边界 + +#### 5.0 L0 / L1 / L2 + +| 级 | 内容 | 通道 | +|----|------|------| +| L0 | title url username email tags passwordHint has* attachmentCount updatedAt notes(全文) fields(非密钥) | list/get 默认 | +| L1 | password/apikey/token 明文 | `vault.reveal`;**登录会话 + 审计**(**不**宣称 OS 级用户手势/CSRF≠手势) | +| L2 | 同上供 AI | `vault.resolve`;默认 **deny**(§5.1);瞬时;transcript 脱敏 | + +#### 5.1 P0 鉴权矩阵(冻结;capabilities_json 延后) + +P0 **不**解析 `DirectoryGrant.capabilities` 中的 `vault.*`(今日无写入路径与 enforcement helper)。映射: + +| 主体 | vault.view | vault.edit | vault.resolve | vault.export | +|------|------------|------------|---------------|--------------| +| local_folder **owner**(manifest owner / 等价) | ✅ | ✅ | ❌(P1 显式开) | ❌ | +| directory grant **write** | ✅ | ✅ | ❌ | ❌ | +| directory grant **read-only** | ❌ | ❌ | ❌ | ❌ | +| share link read | ❌ | ❌ | ❌ | ❌ | +| anonymous | ❌ | ❌ | ❌ | ❌ | + +说明: + +- **协作方默认不可见 vault**(含 read grant)。与「共享目录可读普通文件」分离:vault 是系统敏感空间。Open Q 若产品要 read grant ⇒ view,属 **改矩阵** 的产品决策,P0 实现按上表。 +- `vault.resolve` 默认 **deny**;P1 在 AI policy / 显式设置中改为 `ask` 或 `allow`。L2 文案统一为 deny-by-default,不再写「生产建议 ask」作为默认。 +- reveal:authenticated session + audit,非生物/OS 手势。 + +**P1 capabilities**:再在 grant / ai_policy 写入 `vault.view|edit|resolve|export` 并做 enforcement helper;不阻塞 P0。 + +#### 5.2 通用路径 Deny — **P0 合入门(全表面清单)** + +**共享谓词**(单一实现,禁止复制粘贴分叉): + +```rust +/// rel: workspace-root-relative, `/` 分隔, 已 strip 前导 ./ , 拒绝 .. 段之后再判断 +fn is_vault_sensitive_relative_path(rel: &str) -> bool { + let n = normalize_rel(rel); // 大小写敏感 FS 按原样;比较用统一 trim + n == ".mnote/vault" || n.starts_with(".mnote/vault/") +} +``` + +命中 → **403**,code `vault_path_denied`,body **无** 文件内容(resource/read 亦不得含 `"text"` 字段中的 secret)。 + +**首选 choke point(PR-S 实现策略,减少漏挂)**: + +| 共享 resolve | 今日调用方(已核实) | 动作 | +|--------------|----------------------|------| +| `resolve_local_open_path`(`local_folder_source.rs`) | `open_local_file`;经 `resolve_local_file_open_path` → `read_local_resource` / `write_local_resource` | **在此**调用 `is_vault_sensitive_relative_path`,一次覆盖 open + resource read/write | +| `resolve_local_file_open_path` | 内部调 `resolve_local_open_path` | 继承上列;勿再复制 matcher | +| Pi `resolve_file_path` / `resolve_root_relative_path`(`page_ai_pi/runtime.rs`) | `local_file_read` / `local_file_patch` | **在此** deny,leaf tool 不各自拼规则 | + +**注意**:`stat_local_file` **不**走 `resolve_local_open_path`(自建 `canonical_root.join(path)`),必须在 stat handler **显式** deny,不能假设 choke 自动覆盖。 + +**P0 必须挂接的表面(实现前打勾;编号冻结)**: + +| # | 表面 | 符号 / 路由(已核实) | 动作 | +|---|------|----------------------|------| +| D1 | `GET /api/local-folder/files/open` | `open_local_file` → `resolve_local_open_path` | deny(choke) | +| D2 | `GET /api/local-folder/files/stat` | `stat_local_file`(**独立** path join) | **显式** deny | +| D3 | `GET /api/local-folder/resource/read` | `read_local_resource` → `resolve_local_file_open_path`;返回 JSON **`text` 全文** | deny(choke);**合入门必测** | +| D4 | `POST /api/local-folder/resource/write` | `write_local_resource` → `resolve_local_file_open_path` | deny(choke) | +| D5 | `POST /api/local-folder/assets/upload` | `upload_local_markdown_asset` → `write_local_folder_file_upload` / `write_local_markdown_asset`;`filetree.folder.drop` 的 `targetRelativePath` | 目标 rel 落 vault → deny | +| D6 | 其它 local 写路径:page body / mindmap / mkdir·rename·move·archive 若接受 root-relative 路径 | 至少:`write_local_markdown_page_body`;`write_local_mindmap_data`;tree/local executor 中 rename/move/archive 入口 | 源或目标 vault → deny(vault 只走 vault API) | +| D7 | `mnote.local_file.read` | `page_ai_pi` `local_file_read` | deny | +| D8 | `mnote.local_file.patch` | `local_file_patch` | deny | +| D9 | Pi path resolve | `resolve_file_path`(及 root+rel→abs helper) | 敏感 rel → Err,不返回内容 | +| D10 | tree command / resource archive 指向 vault | local tree executor / archive helpers | deny 或明确 no-op 错误(若入口最终调 D1/D6 resolve,可标「由 shared resolve 覆盖」并单测一条) | +| D11 | OnlyOffice / media 代理 | `onlyoffice.rs` 拼 `fileUrl` → **`/api/local-folder/files/open`** | **由 D1 choke 覆盖**;勿再开平行 open | +| D12 | SSR / document `local-md:` | document / gateway 解码 `local-md:` 相对路径后读盘 | path 在 vault → 不渲染正文;可选 redirect `/vault` | + +**Live `/api/local-folder/*` 路由盘点**(`routes/mod.rs` 已核实,避免 D 表再漏一等公民 content 面): + +| 路由 | Handler | vault path deny? | +|------|---------|-------------------| +| `GET .../files/open` | `open_local_file` | **D1**(choke) | +| `GET .../files/stat` | `stat_local_file` | **D2**(显式) | +| `GET .../resource/read` | `read_local_resource` → JSON **`text` 全文** | **D3**(choke,合入门) | +| `POST .../resource/write` | `write_local_resource` | **D4**(choke) | +| `POST .../assets/upload` | `upload_local_markdown_asset`(含 `filetree.folder.drop` + `targetRelativePath`) | **D5** | +| `GET .../events` | local-folder event bus / watcher SSE | **N/A content**:事件不回文件正文;watcher 已滤 `.mnote`(§4.2 non-goal) | +| `POST .../shared-cache/record` | `record_shared_cache` | **N/A**:写 `.mnote/share-cache.json` 元数据,不读任意 rel 正文 | +| `POST .../sync/pending-change` | `record_sync_pending_change` | **N/A**:sync 元数据 | +| `POST .../sync/conflict-report` | `write_sync_conflict_report` | **N/A**:报告写 `.mnote/sync-reports` | +| `GET .../workspaces/default` | `create_default_local_workspace` 等 | **N/A**:workspace bootstrap,无任意 path 读盘 | +| `ANY .../ocr/{jobs,status,read,insert,delete}` | `retired_local_ocr_endpoint` | **已退役**(指向 LightRAG);P0 **不**新增 OCR vault 路径;若未来复活须走 `is_vault_sensitive_relative_path` | +| (非 local-folder 但相关)`GET /api/tree/local-folder-watch` | legacy / 侧车 | 以 tree live 为准;**不**作为 vault content read 面 | + +**结论**:今日能回任意 workspace-relative **文件字节/全文** 的 HTTP 面仅 **open / stat / resource/read / resource/write / assets/upload**——均已进 D1–D5。其它 local-folder 路由不构成 secret 正文旁路。Pi / SSR / OnlyOffice / tree write 见 D6–D12。 + +**单元测试(每个 D1–D5、D7–D9 至少一条;D3 为合入门)**: + +- D1 open / D2 stat / **D3 resource/read**:`.mnote/vault/entries/x.md` → 403 `vault_path_denied`;响应体无 fixture password、`password:` 样例串;**D3 无 `text` 明文**。 +- D4 resource/write / D5 assets/upload 目标 vault → 403。 +- D7–D9:`mnote.local_file.read` / patch / resolve → deny,无 content。 + +**原生 Pi / 非 MNote 文件工具残留风险**: + +- 强制:凡经 MNote tool bridge 的路径检查必须 deny。 +- 若 Pi 进程 cwd/sandbox 含 workspace root 且存在 **绕过 MNote** 的原生 read:属残留风险。P0 文档披露;缓解:Pi sandbox 不把 `.mnote/vault` 列入可写/可读 allowlist(若 runtime 支持 path deny 列表则配置);无法 100% 时在 AI 设置提示「请勿将 vault 目录加入 agent 原生工作区」。 +- 不把 vault 路径写入 `AiAccessScope.allowed_file_paths` / `allowed_roots` 的子路径 allow。 + +**顺序**:**PR-S(安全锁)在任意默认可写 secret 的 API 对非测试环境生效之前 merge**(见 PR Plan)。Feature flag 默认 **关** 直至 **P0-S** 全绿(含 D1–D5、D7–D9;尤其 D3 resource/read)。 + +#### 5.3 威胁模型(摘要) + +| 威胁 | 严重度 | 缓解 | +|------|--------|------| +| HTTP open/stat 读 vault | 高 | D1–D2 + 测试合入门 | +| HTTP **resource/read** 回全文 | 高 | **D3** + choke `resolve_local_open_path` | +| resource/write · assets/upload 写 vault | 高 | D4–D5 | +| Agent local_file 读 vault | 高 | D7–D9 | +| RAG 索引 vault | 高 | exclude + 单测 | +| list/get 回明文 | 高 | mask + reveal 分端 | +| 日志打印 secret | 高 | reveal/resolve/rebuild 错误路径禁 value | +| 双轨 `个人/密码/` | 高 | §7.4 警告 + 可选 exclude | +| 磁盘物理读取 | 高(接受) | 非加密声明 | +| 掩码写回 | 中 | sentinel reject | +| XSS 读 DOM | 中 | 最短明文展示 | + +--- + +### 6. AI 分阶段 + +#### P0 + +- 无 `mnote.vault.*` 工具暴露。 +- D7–D9(及 HTTP D1–D5,含 **resource/read**)deny 生效;AI / 通用 HTTP 读 vault 失败且无 body。 + +#### P1 — **多 agent 唯一策略(2026-07-20 冻结并落地)** + +**策略名:单 AI 密码本 + 单 resolve 通道 + 多 agent 只消费 AI 本。** + +| 项 | 冻结 | +|----|------| +| 凭证池 | 仅 AI 密码本(`MNOTE_AI_VAULT_ACTOR`,默认 mnote-e2e) | +| 读密 | **仅** `mnote.vault.resolve` / `POST /api/vault/ai/items/{id}/resolve` / CLI | +| 禁止 | 通用 file / local_file / open / resource / RAG 读 `.mnote/vault/**` | +| 用户 vault | 人用 reveal;**resolve 禁止**(`vault_resolve_ai_only`) | +| 长期 | AI 本内条目即长期授权;撤权=删/软删 AI 本条目 | +| 快速 | 本机 HTTP/CLI,不经浏览器 | +| 策略 SSOT | `/home/lix/.agent-infra/vault-policy.md` + `$mnote-vault` + Paseo `appendSystemPrompt` | + +- **已落地**:`list_ai` / `get_ai` / `resolve` API;Pi `mnote.vault.list|get|resolve`(默认 allow,plan 模式 resolve deny);receipt 脱敏 value;`scripts/mnote-vault-cli.js`。 +- transcript:agent 应使用 `transcriptHint`(已解析 field),不把 value 贴进聊天。 +- skills:`skills/mnote-vault` + 全局 symlink + capability pack。 + +#### P2 + +- 协助录入确认流、attachment cookies、自注册回写、export;按 credential/run grant(可选)。 + +--- + +### 7. 迁移(`个人/密码/`) + +#### 7.1 源形态 + +- 惯例路径:`{workspaceRoot}/个人/密码/**/*.md`(**用户环境观察**,数量因工作区而异)。 +- 自由 md;导入前 **仍在** FileTree/RAG/search 范围内。 + +#### 7.2 向导 + +preview → 用户勾选 → apply;冲突 skip/副本;**禁止静默覆盖**;默认不删源。 + +#### 7.3 启发式 + +frontmatter 优先;否则中英关键行;title=文件名 stem;低置信人工确认 password。 + +#### 7.4 双轨残留风险(正式产品态度) + +正式 vault **不能**单独声称「工作区密码已卫生」。在源目录仍存在期间: + +1. Empty state / 导入页 **固定警告**:「`个人/密码/` 等目录仍可能被搜索与知识库索引;请导入后归档。」 +2. 推荐 apply 后可选移动源到 `个人/密码/_imported/`,并将 `_imported` 或整树加入 knowledge/local-search **excludePatterns**(实现可复用已有 exclude 设置)。 +3. **可选**(Phase M+):内置 exclude 候选 `个人/密码/**`(默认 **不** 静默全局排除,避免误伤同名笔记;UI 一键启用)。 +4. 设计稿与验收 **不** 把「端到端密钥卫生」标为 P0 done,除非源已导入或 exclude。 + +--- + +### 8. Observability + +**P0 唯一审计 sink**:`{workspaceRoot}/.mnote/vault/audit.jsonl`(append-only)。 + +示例行: + +```json +{"ts":"2026-07-19T09:30:00Z","action":"reveal","actorId":"user_x","credentialId":"cred_…","field":"password","requestId":"…","ok":true} +``` + +禁止字段:`value` / `password` / `body` / frontmatter 原文。 + +**Rebuild / 解析错误**:同样只写 id/path/code。 + +可选 metrics:list 延迟、reveal 计数、deny 计数。 + +**运维**:index 损坏 → `POST /api/vault/reindex` 或 list 自动 rebuild;说明写入 support runbook 一节(本设计 §2.3)。 + +--- + +### 9. Rollout + +| 项 | 策略 | +|----|------| +| Feature flag | `MNOTE_VAULT`:**默认 0(关)**。允许默认开的门闩:**P0-S 全绿**(含 D1–D5、D7–D9 单测,**尤其 D3 resource/read**)+ 建议 **P0-E smoke** 绿后再按环境打开。**无** `P0-D` 阶段名 | +| 入口 | flag 关:隐藏 quick-action、API 404 | +| 密钥写入 | **禁止** 在 flag 默认开且无 path deny(P0-S)时合入 | +| 回滚 | flag 关 + 隐藏入口;**保留** `.mnote/vault` 数据 | +| 迁移 | 不自动 import;导入前建议用户备份源目录 | + +--- + +### 10. Alternatives Considered + +| 方案 | 描述 | 优点 | 缺点 | 结论 | +|------|------|------|------|------| +| A. 普通 md + FileTree | 继续 `个人/密码/` | 零开发 | 无分级/RAG 风险 | 否决 | +| B. Control-plane DB 存密 | Turso 表 | 易审计 | 违背 local-first 正文真相 | 否决 | +| C. `.mnote/vault` + 专用页 + mnote-web API | 本文 | 对齐 trash、可 deny、可分级 | 新模块 | **采用** | +| D. Modal 如 trash | 不改 URL | 上下文保持 | 用户要专门页;表单空间不足 | 否决 IA | +| E. 一期加密 | master password | 真保密 | 工期/UX/AI resolve | 延期 | +| F. 单文件 SQLite/JSONL vault | `.mnote/vault/vault.db` | 索引快、无 per-file secret 扫描 | 人读救援差、与 md 生态/git diff 差 | **否决 P0**(人读救援优先,K4) | +| G. 先做 trash 式 modal 再升级全页 | 增量 UI | 更快出壳 | 与已确认 IA 冲突、返工 | 否决 | + +--- + +## Security & Privacy + +见 §5。补充:Clipboard 清空 best-effort;SSR 首屏禁止 secret;path canonicalize + vault prefix deny。 + +--- + +## Open Questions + +1. 图标 `lock` vs `key`?建议 lock。 +2. 是否永久废弃「文件总览」产品名?当前 `/files` 仅 redirect。 +3. **已冻结 P0**:read grant **无** vault.view;若产品要协作者只读 vault → 改 §5.1 矩阵另开变更。 +4. **已冻结**:`vault.resolve` 默认 **deny**。 +5. 导入候选路径是否扩展 `密码/`、`Passwords/`?默认仅 `个人/密码`。 +6. credentialId:建议 ULID/UUID。 +7. empty state 自动 ensure?建议是。 + +--- + +## Key Decisions + +| # | 决策 | 理由 | +|---|------|------| +| K1 | 存储 `{workspaceRoot}/.mnote/vault/` | 系统空间;FileTree/RAG 已忽略 `.mnote` | +| K2 | 专用 `/vault` 全页 CRUD,非 modal/tiptap | 用户明确要求 | +| K3 | 死链 `/files`/`inventory_2` → 密码箱 | 固定入口 | +| K4 | per-entry frontmatter md + L0 index | 人读救援、git 友好;否决纯 SQLite P0 | +| K5 | index 只存 L0;rebuild allowlist 解析 | 防 secret 进索引/日志 | +| K6 | **P0 软删除** + 工作台回收站;不进全局 trash | 降泄露;与 restore/purge API 同阶段 | +| K7 | L0/L1/L2;reveal=会话+审计 | 最小暴露 | +| K8 | 通用文件面 **server deny** vault;AI 仅 `mnote.vault.*`(P1) | 堵住 open/stat/local_file 实锤缺口 | +| K9 | 一期不加密 at-rest | 工期与诚实威胁模型 | +| K10 | 迁移可选、不静默覆盖;双轨风险明示 | 保护旧数据与预期 | +| K11 | 刷新=command 结果 + 手动刷新;**非** watcher;禁 setInterval | 符合现网 watcher 行为 | +| K12 | 大类 `12-vault`;**非** 04-tree 树 bug 默认 owner | 系统空间产品 | +| K13 | **P0 owner = mnote-web 系统空间 API**;非 tree kernel / 非 P0 bridge command | 对齐 trash;避免假 kernel | +| K14 | Secret PATCH:省略=保留;clear/null=清空;set=写入;拒掩码哨兵 | 防擦密与存 `••••` | +| K15 | Feature flag 默认 **关**;**P0-S**(含 D3 resource/read)绿前不默认可写 secret | 安全顺序;无 P0-D 阶段 | +| K16 | P0 审计 = `.mnote/vault/audit.jsonl` only | 可执行、无双 sink 歧义 | +| K17 | P0 鉴权矩阵:owner/write⇒view+edit;read/share⇒无 vault;resolve 默认 deny | 可实现;capabilities 延后 P1 | +| K18 | `fields` 仅非密钥 L0;标准 secret 三字段 + passwordHint L0;**hint==password → 仅 400 reject** | 防旁路;合同无 auto-clear | +| K19 | 附件 path 相对 vault root;专用 GET;禁 files/open | 防 cookies 绕过 | +| K20 | path deny 优先挂 `resolve_local_open_path` / Pi resolve;**D3 resource/read** 一等公民 | 防 leaf-only 漏挂 | + +--- + +## 执行清单(Checklist) + +### Phase 0 — 合同 + +- [ ] 迁入 `design/12-vault/process/`;更新 `design/README.md` 主线(注明 12-vault 为系统空间,非 tree-domain) +- [ ] 冻结 schema / K1–K20 / §5.1 矩阵 / §3.5 PATCH +- [ ] 冻结 deny 表面 **D1–D12** 与 `is_vault_sensitive_relative_path`;choke = `resolve_local_open_path` + Pi resolve + stat 显式 + +### Phase P0-S — 安全锁(**先于或与首个 secret 写入同 PR 合并门**) + +- [ ] 实现 `is_vault_sensitive_relative_path` + 单测 +- [ ] **choke**:`resolve_local_open_path` 内 deny(覆盖 D1 open、D3 resource/read、D4 resource/write) +- [ ] D2 `stat_local_file` **显式** deny(不走 resolve) +- [ ] D1 open / D2 stat / **D3 `GET .../resource/read`** → 403 `vault_path_denied`;D3 响应无 `"text"` 明文 / fixture password +- [ ] D4 resource/write / D5 assets/upload 目标 vault → 403 +- [ ] D6 至少一条 page-body 或 tree 写路径 deny 单测 +- [ ] D7–D9 local_file read/patch + resolve_file_path deny +- [ ] knowledge_rag:vault 路径不可 ingest(单测) +- [ ] local_search:vault 不进 index;用户 includePaths 强指 vault → 拒绝 +- [ ] FileTree 不可见(`.mnote` + 断言) +- [ ] `MNOTE_VAULT` 默认 0 时行为符合 §9(门闩引用 **P0-S / P0-E**,无 P0-D) + +### Phase P0-A — 存储 + +- [ ] ensure 含 vault + trash 子目录 +- [ ] vault_store:entry 读写、index 原子更新、L0 allowlist rebuild +- [ ] soft-delete 移动文件 + status +- [ ] restore / purge +- [ ] audit.jsonl append(reveal/delete/…) +- [ ] parse error 日志无 password 单测 +- [ ] `cargo test -p mnote-web`:模块名建议 `vault_store` / `vault_path` / `vault_api`(固定,不用「或等价」含糊) + +### Phase P0-B — HTTP + +- [ ] 注册 §3.2 全部 P0 路由 +- [ ] get/list mask;reveal 审计无 value +- [ ] update title-only keeps password 单测 +- [ ] mask sentinel → 400 +- [ ] fields 密钥键 → 400 +- [ ] passwordHint == password → 400 `vault_hint_equals_secret`(无 auto-clear) +- [ ] flag 关 → 404 + +### Phase P0-C — SSR + 入口 + UI + +- [ ] `VaultPage` + `mnote-vault-workbench` +- [ ] bootstrap list **无** password 明文;HTML 单测/fixture 断言 +- [ ] layout 密码箱入口 + **明确** script:`layout.rs` 增加 `vault-workbench-runtime.js` module script(同侧其它 browser runtime),并在 `mnote-ui-runtime.js` 侧 import 初始化 +- [ ] deep-link `?id=`、dirty discard、回收站 tab、手动刷新 +- [ ] 无 setInterval:对 `vault-workbench-runtime.js` 做静态扫描(`rg setInterval`) +- [ ] `/files` → `/vault` 302 + +### Phase P0-E — Smoke + +- [ ] `scripts/task-vault-crud-smoke.js`: + - 登录 e2e 账号;开 root;ensure + - create → list 见 L0 → get 无明文 password + - update title only → 磁盘 password 不变 + - reveal → 得明文;audit.jsonl 有行且 `rg` 无 password 值 + - delete → trash 路径存在;restore;purge 干净 + - `GET .../files/open?path=.mnote/vault/entries/...` → 403 + - `GET .../resource/read?path=.mnote/vault/entries/...` → 403,body 无 fixture password / 无 secret `text` + - `GET .../files/stat?path=.mnote/vault/entries/...` → 403(或等价 deny,无敏感元数据滥用) + - SSR `/vault` HTML 不含 fixture password 字符串 +- [ ] 禁止 sqlite3 直写 CP + +### Phase M — 迁移 + +- [ ] preview/apply;冲突策略;双轨警告 UI +- [ ] 可选 archive + exclude 提示 +- [ ] fixture 假密码导入 smoke(3 条) + +### Phase P1 — AI tools + capabilities + +- [ ] `mnote.vault.*` 注册;resolve 默认 deny +- [ ] grant/policy 写入 vault.* 可选 +- [ ] agent local_file.read vault deny smoke(回归) +- [ ] attachment GET/POST + +### Phase P2 / 收口 + +- [ ] 录入/cookies/export;ARCHITECTURE + AGENTS 一行;design → done + +--- + +## Risks + +| 风险 | 严重度 | 缓解 | +|------|--------|------| +| 误认已加密 | 高 | 页眉声明 | +| open/stat 合入漏挂 | 高 | P0-S 门禁 + 全表面表 | +| 双轨 `个人/密码/` | 高 | §7.4 | +| 导入误解析 | 中 | 低置信人工确认 | +| index 漂移 | 中 | reindex + 原子写 | +| 原生 Pi 绕过 MNote | 中 | 披露 + sandbox 建议 | +| revision 冲突丢更新 | 低–中 | re-fetch;可选 baseRevision | + +--- + +## References + +- ARCHITECTURE.md / AGENTS.md +- design 4-37 / 7-18 / 5-34 / 4-48 +- 代码:`layout.rs`、`local_folder_source.rs`(ensure/open/stat/**resource read·write**/upload/`resolve_local_open_path`)、`local_folder_watcher_registry.rs`、`gateway.rs` trash、`page_ai_pi/runtime.rs`、`core-protocol` AiAccessScope、`knowledge_rag.rs`、`local_search_index.rs` + +--- + +## PR Plan + +### PR-S — Vault path deny + index exclude locks(**安全门,可先于或与 PR1 同列车**) + +- **Title**:`security(vault): deny .mnote/vault on open/stat/resource/local_file and lock RAG/search` +- **Files**:`vault_path.rs`(新)、`local_folder_source.rs`(**`resolve_local_open_path` choke**、`stat_local_file` 显式、upload/write helpers)、`page_ai_pi/runtime.rs`(resolve_file_path、local_file_read|patch)、`knowledge_rag`/`local_search_index` 测试 +- **Dependencies**:无 +- **Description**:实现 **D1–D12** 中的可触达面;**必须**含 D3 `resource/read` 单测。优先 choke 而非 leaf 复制。**无** vault 业务写入。合并门:P0-S 红线单测绿。 + +### PR1 — Vault store + ensure + soft-delete 文件语义 + +- **Title**:`feat(vault): vault store, ensure dirs, soft-delete on disk` +- **Files**:`local_folder_source` ensure、`vault_store.rs`、audit.jsonl、单元测试 `vault_store` +- **Dependencies**:PR-S(或同 PR 内先 path deny) +- **Description**:目录、md 合同、index、soft-delete/restore/purge 文件层;**无 HTTP** 或仅测内函数。 + +### PR2 — Vault HTTP API(CRUD + reveal + restore/purge + mask PATCH) + +- **Title**:`feat(vault): /api/vault CRUD, reveal, restore/purge with secret PATCH contract` +- **Files**:`routes/vault.rs`、`routes/mod.rs`、flag `MNOTE_VAULT` 默认 0、API 测试 +- **Dependencies**:PR-S、PR1 +- **Description**:§3.2 P0 路由;§3.5 语义;list/get mask;审计。flag 默认关。 + +### PR3 — SSR `/vault` + `/files` redirect + +- **Title**:`feat(vault): SSR /vault workbench shell` +- **Files**:`ssr/pages/vault.rs`、page route、styles、HTML 无 secret 测试 +- **Dependencies**:PR2 +- **Description**:PageLayout 工作台壳 + bootstrap L0。 + +### PR4 — Sidebar 入口 + vault-workbench-runtime + +- **Title**:`feat(vault): sidebar entry and vault workbench runtime` +- **Files**:`layout.rs`(链接 + **script 标签**)、`browser/vault-workbench-runtime.js`、`mnote-ui-runtime.js` import、回收站 tab、deep-link、dirty discard +- **Dependencies**:PR3 +- **Description**:完整 P0 UI;手动刷新;`rg setInterval` 干净。 + +### PR5 — Soft-delete UI 打磨与并发/revision(瘦身) + +- **Title**:`feat(vault): recycle-bin UX polish and revision conflict handling` +- **Files**:runtime + 少量 API(若 baseRevision) +- **Dependencies**:PR4 +- **Description**:**不再**塞 path deny / RAG(已在 PR-S)。专注回收站体验与两 tab 冲突。 + +### PR6 — Browser smoke + +- **Title**:`test(vault): browser smoke for vault CRUD and path deny` +- **Files**:`scripts/task-vault-crud-smoke.js`、TESTING_REFERENCE 条目 +- **Dependencies**:PR4、PR-S(deny 断言) +- **Description**:§ Phase P0-E 全部断言。 + +### PR7 — 迁移向导 + +- **Title**:`feat(vault): import wizard from informal 个人/密码 notes` +- **Files**:import API、UI、双轨警告、fixture 测试 +- **Dependencies**:**PR2 + PR4**(稳定 create + UI) +- **Description**:preview/apply;不静默覆盖。 + +### PR8 — AI `mnote.vault.*`(P1) + +- **Title**:`feat(vault): Pi Lab mnote.vault tools with resolve default deny` +- **Files**:`page_ai_pi/runtime.rs`、skills、短交叉引用 7-18 +- **Dependencies**:PR2、PR-S +- **Description**:tools + 脱敏 + 回归 local_file deny。 + +### PR9 — 附件 cookies(**不**强制依赖 PR8) + +- **Title**:`feat(vault): vault attachment upload/download for cookies/session` +- **Files**:attachment API + UI;vault_store attachments +- **Dependencies**:PR2、PR4(UI 可后置);**不**依赖 PR8 +- **Description**:专用 GET;禁 files/open;path 无 `..`。 + +### PR10 — 文档收口 + +- **Title**:`docs(vault): ARCHITECTURE, AGENTS, design README; move 12-1 to done` +- **Files**:ARCHITECTURE、AGENTS、design/README、迁 done +- **Dependencies**:PR6 绿;P0 可宣布 +- **Description**:系统空间定位写清,避免 bug 误派 04-tree。 + +--- + +## Revision Summary(设计稿内) + +2026-07-19 review 修订要点: + +1. 全表面 path deny(open/stat/write/Pi resolve/local_file)+ 合入门 PR-S。 +2. 刷新模型改为 command/手动;撤销 watcher 假说。 +3. P0 软删除 + restore/purge HTTP/UI 对齐。 +4. Secret PATCH 语义与掩码哨兵拒绝。 +5. SecretField / passwordHint / fields L0 合同。 +6. Flag 默认关;PR5 瘦身;审计单 sink。 +7. 鉴权矩阵冻结;resolve 默认 deny;reveal 非 OS 手势。 +8. Owner = mnote-web 系统空间,非假 kernel。 +9. 附件路径与专用 GET;5-34 为启发。 +10. 双轨 `个人/密码/` 风险与 checklist/PR/K13–K19 同步。 + +2026-07-19 re-review 修订: + +11. **D3** `GET /api/local-folder/resource/read`(`read_local_resource`)一等公民 deny;D4 write / D5 upload 具名;表扩至 **D1–D12**。 +12. 首选 choke:`resolve_local_open_path`(+ Pi resolve);`stat_local_file` 显式(不走 resolve)。 +13. §9 / checklist 去掉幽灵 **P0-D**;flag 门闩 = **P0-S**(+ 建议 P0-E)。 +14. `passwordHint == password` → **仅** `400 vault_hint_equals_secret`(K18)。 +15. K20;smoke 含 resource/read + stat。 +16. §5.2 补 **live `/api/local-folder/*` 路由盘点**(mod.rs):content 面仅 open/stat/resource/read|write/assets/upload;OCR 已退役;events/sync/cache 非正文旁路。 diff --git a/design/README.md b/design/README.md index 171a7217..3af0ca07 100644 --- a/design/README.md +++ b/design/README.md @@ -1,12 +1,12 @@ # design 设计稿索引 -> 更新时间:2026-06-28 +> 更新时间:2026-07-19 > -> 当前产品口径:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Pi Rust Page AI + opencode/native agent 边界 + LightRAG 知识库融合 + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 +> 当前产品口径:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Pi Rust Page AI + LightRAG 知识库融合 + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。 > -> 当前上位主线:local-first MVP 已初步完成;local-first workspace 是默认数据形态,本地 `.md` 是页面正文真相,Rust kernel / projection / command 持有语义,Rust SQLite control-plane 持有默认 auth、membership、share grants、sync state、AI policy、Page AI runtime session 控制面;Convex / 服务端只作为历史迁移源、显式 cloud source、compat 和 sync replica。入口设计见: -> `/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/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md` +> 当前上位主线:local-first MVP 已初步完成;local-first workspace 是默认数据形态,本地 `.md` 是页面正文真相,Rust kernel / projection / command 持有语义,Turso/libSQL control-plane 持有默认 auth、membership、share grants、sync state、AI policy、Page AI runtime session 控制面;Convex / 服务端只作为历史迁移源、显式 cloud source、compat 和 sync replica。入口设计见: +> `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md` +> `/mnt/Data1T/mnote/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` > > 后续新增设计默认以 MVP 后阶段为基线:优先推进 `WorkspacePath/ObjectIdentity`、`DocumentBuffer/BufferStore`、Page Aggregate compat 瘦身、tree command context、live cache 统一、agent diff / 冲突合并 / 本地索引 / 分享同步产品化;不要再把旧 Convex / Next / BlockNote 主链、根 `convex/` functions 源码或 Convex Auth 默认控制面当作新增能力默认方向。 > @@ -24,9 +24,10 @@ ## 当前优先级入口 - 当前有效架构口径,请先看: - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` -- MVP 后阶段剩余 `process/` 的执行顺序,请看: - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` + `/mnt/Data1T/mnote/ARCHITECTURE.md` + (`CURRENT_ARCHITECTURE.md` 仅为兼容指针,不再写正文) +- MVP 后阶段架构收口与执行顺序,请看: + `/mnt/Data1T/mnote/design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` - Runtime 模块级拆分完成态,请看: `/mnt/Data1T/mnote/design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md` - 历史 `01-05-current-priority-overview` 已迁入 `design/old/`,只能作为归档快照,不再作为当前入口。 @@ -83,7 +84,7 @@ - `90-reference/` - 跨域生态参考资料,不参与 `[done]/[process]/[draft]/[reference]/[recycle]` 状态判断 - - 引用时只能作为生态资料或背景材料,不能覆盖 `CURRENT_ARCHITECTURE.md`、`ARCHITECTURE.md`、`AGENTS.md`、`1-8` 当前执行顺序或对应主线 `process/` / `done/` 设计稿 + - 引用时只能作为生态资料或背景材料,不能覆盖 `ARCHITECTURE.md`、`AGENTS.md`、对应主线 `process/` / `done/` 设计稿 - `old/` - 已废弃或被替代的历史稿件,标题统一标记 `[recycle]` - 每个大类继续按 `process/` 与 `done/` 分层 diff --git a/design/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md b/design/old/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md similarity index 98% rename from design/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md rename to design/old/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md index 1ea4e82d..ca83f574 100644 --- a/design/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md +++ b/design/old/01-tree-first-graph-kernel/done/1-10-batch-c-p1-resource-buffer-gfm-convex-checklist-v1.md @@ -1,4 +1,4 @@ -# 1-10 [done] Batch C P1 Resource / BufferStore / GFM / Convex Evidence Checklist v1 +# [recycle] 1-10 [done] Batch C P1 Resource / BufferStore / GFM / Convex Evidence Checklist v1 > 创建时间:2026-05-21 > diff --git a/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md b/design/old/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md similarity index 98% rename from design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md rename to design/old/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md index 165518fd..af086489 100644 --- a/design/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md +++ b/design/old/01-tree-first-graph-kernel/done/1-11-batch-d-p1-small-tail-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 1-11 [done] Batch D P1 小尾项执行 checklist v1 +# [recycle] 1-11 [done] Batch D P1 小尾项执行 checklist v1 > 创建时间:2026-05-21 > diff --git a/design/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md b/design/old/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md similarity index 99% rename from design/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md rename to design/old/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md index 3abfe934..3ea13700 100644 --- a/design/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md +++ b/design/old/01-tree-first-graph-kernel/done/1-12-batch-e-p1-p2-tail-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 1-12 [done] Batch E P1/P2 尾项执行 checklist v1 +# [recycle] 1-12 [done] Batch E P1/P2 尾项执行 checklist v1 > 创建时间:2026-05-21 > diff --git a/design/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md b/design/old/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md similarity index 99% rename from design/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md rename to design/old/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md index 1bb04e62..f39491c6 100644 --- a/design/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md +++ b/design/old/01-tree-first-graph-kernel/done/1-13-batch-f-p1-p2-active-tail-checklist-v1.md @@ -1,4 +1,4 @@ -# 1-13 [process] Batch F P1/P2 活跃尾项执行 checklist v1 +# [recycle] 1-13 [process] Batch F P1/P2 活跃尾项执行 checklist v1 > 创建时间:2026-05-21 > diff --git a/design/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md b/design/old/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md similarity index 98% rename from design/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md rename to design/old/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md index c8345d6c..2bbafe03 100644 --- a/design/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md +++ b/design/old/01-tree-first-graph-kernel/done/1-14-batch-h-p2-live-cache-command-context-closure-v1.md @@ -1,4 +1,4 @@ -# 1-14 [process] Batch H P2 live cache / command context 收口 checklist v1 +# [recycle] 1-14 [process] Batch H P2 live cache / command context 收口 checklist v1 > 创建时间:2026-05-21 > diff --git a/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md b/design/old/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md similarity index 99% rename from design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md rename to design/old/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md index 3555f1da..e5411b7d 100644 --- a/design/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md +++ b/design/old/01-tree-first-graph-kernel/done/1-3-current-priority-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 1-3 [process] 当前主线持续推进 checklist v1 +# [recycle] 1-3 [process] 当前主线持续推进 checklist v1 > 创建时间:2026-05-19 > diff --git a/design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md b/design/old/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md similarity index 99% rename from design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md rename to design/old/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md index d71d15a3..6136327f 100644 --- a/design/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md +++ b/design/old/01-tree-first-graph-kernel/done/1-6-next-phase-gap-closure-checklist-v1.md @@ -1,4 +1,4 @@ -# 1-6 [process] 下一阶段缺口补齐 checklist v1 +# [recycle] 1-6 [process] 下一阶段缺口补齐 checklist v1 > 创建时间:2026-05-19 > diff --git a/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md b/design/old/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md similarity index 99% rename from design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md rename to design/old/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md index 52cec139..0604a122 100644 --- a/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md +++ b/design/old/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md @@ -1,4 +1,4 @@ -# 1-8 [process] MVP 后阶段 process 执行总序 v1 +# [recycle] 1-8 [process] MVP 后阶段 process 执行总序 v1 > 创建时间:2026-05-21 > diff --git a/design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md b/design/old/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md similarity index 99% rename from design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md rename to design/old/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md index 200f5bdc..a9712bca 100644 --- a/design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md +++ b/design/old/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md @@ -1,4 +1,4 @@ -# 3-25 Local Folder MinerU OCR Sidecar Checklist v1 +# [recycle] 3-25 Local Folder MinerU OCR Sidecar Checklist v1 > 创建时间:2026-05-29 > 状态:`process` diff --git a/design/03-rust-web/done/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md b/design/old/03-rust-web/done/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md similarity index 99% rename from design/03-rust-web/done/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md rename to design/old/03-rust-web/done/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md index 20784188..aed14e7d 100644 --- a/design/03-rust-web/done/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md +++ b/design/old/03-rust-web/done/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md @@ -1,4 +1,4 @@ -# 3-7 [done] Rust Web 3000 Wolai UI Parity Implementation Plan v1 +# [recycle] 3-7 [done] Rust Web 3000 Wolai UI Parity Implementation Plan v1 > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. diff --git a/design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md b/design/old/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md similarity index 99% rename from design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md rename to design/old/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md index 6417bdbb..b2315f5a 100644 --- a/design/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md +++ b/design/old/03-rust-web/done/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md @@ -1,4 +1,4 @@ -# 3-8 [done] Rust Web 3000 Wolai UI Parity Checklist v1 +# [recycle] 3-8 [done] Rust Web 3000 Wolai UI Parity Checklist v1 > 更新时间:2026-04-29 > diff --git a/design/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md b/design/old/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md similarity index 99% rename from design/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md rename to design/old/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md index 018d2dc9..9c4cbc52 100644 --- a/design/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md +++ b/design/old/04-tree-domain/done/4-11-tree-rust-family-final-renderer-and-host-thinning-checklist-v1.md @@ -1,4 +1,4 @@ -# 4-11 [done] 树域 Rust 家族 final renderer 与宿主收薄清单 v1 +# [recycle] 4-11 [done] 树域 Rust 家族 final renderer 与宿主收薄清单 v1 > 更新时间:2026-04-26 > diff --git a/design/04-tree-domain/done/4-12-task-003-page-tree-final-renderer-execution-checklist-v1.md b/design/old/04-tree-domain/done/4-12-task-003-page-tree-final-renderer-execution-checklist-v1.md similarity index 97% rename from design/04-tree-domain/done/4-12-task-003-page-tree-final-renderer-execution-checklist-v1.md rename to design/old/04-tree-domain/done/4-12-task-003-page-tree-final-renderer-execution-checklist-v1.md index 89d8542e..bc3a8e86 100644 --- a/design/04-tree-domain/done/4-12-task-003-page-tree-final-renderer-execution-checklist-v1.md +++ b/design/old/04-tree-domain/done/4-12-task-003-page-tree-final-renderer-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 4-12 [done] task-003 page tree final renderer 执行清单 v1 +# [recycle] 4-12 [done] task-003 page tree final renderer 执行清单 v1 > 口径修正(2026-04-28):本文件的 `done` 状态只表示 page tree 的 Rust initial DOM、hydration patch 与 state-family 阶段完成;不表示 `4-11` 所定义的 final Rust DOM shell 已完成。默认主路径仍依赖 `TreeShellIframeHost` / `iframe_srcdoc` 时,不得引用本文宣称 page tree final renderer 已最终收口。 > diff --git a/design/04-tree-domain/done/4-13-task-004-picker-final-renderer-state-family-checklist-v1.md b/design/old/04-tree-domain/done/4-13-task-004-picker-final-renderer-state-family-checklist-v1.md similarity index 97% rename from design/04-tree-domain/done/4-13-task-004-picker-final-renderer-state-family-checklist-v1.md rename to design/old/04-tree-domain/done/4-13-task-004-picker-final-renderer-state-family-checklist-v1.md index ff489e19..02966d4f 100644 --- a/design/04-tree-domain/done/4-13-task-004-picker-final-renderer-state-family-checklist-v1.md +++ b/design/old/04-tree-domain/done/4-13-task-004-picker-final-renderer-state-family-checklist-v1.md @@ -1,4 +1,4 @@ -# 4-13 [done] task-004 picker final renderer state family 执行清单 v1 +# [recycle] 4-13 [done] task-004 picker final renderer state family 执行清单 v1 > 口径修正(2026-04-28):本文件的 `done` 状态只表示 picker state-family / runtime reducer / hydrated DOM patch 阶段完成;不表示 picker 已脱离 `TreeShellIframeHost` 的 inline JS DOM shell。final DOM shell 收口以 `4-18-tree-final-dom-shell-cutover-hard-gate-v1.md` 为准;该硬门禁现已在同目录 `done/` 收口。 > diff --git a/design/04-tree-domain/done/4-14-task-005-file-tree-final-renderer-capability-checklist-v1.md b/design/old/04-tree-domain/done/4-14-task-005-file-tree-final-renderer-capability-checklist-v1.md similarity index 97% rename from design/04-tree-domain/done/4-14-task-005-file-tree-final-renderer-capability-checklist-v1.md rename to design/old/04-tree-domain/done/4-14-task-005-file-tree-final-renderer-capability-checklist-v1.md index e00ce25e..9d0416e8 100644 --- a/design/04-tree-domain/done/4-14-task-005-file-tree-final-renderer-capability-checklist-v1.md +++ b/design/old/04-tree-domain/done/4-14-task-005-file-tree-final-renderer-capability-checklist-v1.md @@ -1,4 +1,4 @@ -# 4-14 [done] task-005 file tree final renderer 能力保留执行清单 v1 +# [recycle] 4-14 [done] task-005 file tree final renderer 能力保留执行清单 v1 > 口径修正(2026-04-28):本文件的 `done` 状态只表示 file tree 的 Rust initial DOM、row contract、selection state family 与能力保留阶段完成;不表示 file tree 已完成 final Rust DOM shell 切换。默认主路径仍依赖 `iframe_srcdoc` 时,不得用本文作为最终完成依据。 > diff --git a/design/04-tree-domain/done/4-15-task-006-compat-host-thinning-execution-checklist-v1.md b/design/old/04-tree-domain/done/4-15-task-006-compat-host-thinning-execution-checklist-v1.md similarity index 97% rename from design/04-tree-domain/done/4-15-task-006-compat-host-thinning-execution-checklist-v1.md rename to design/old/04-tree-domain/done/4-15-task-006-compat-host-thinning-execution-checklist-v1.md index 23cff294..2584e4a7 100644 --- a/design/04-tree-domain/done/4-15-task-006-compat-host-thinning-execution-checklist-v1.md +++ b/design/old/04-tree-domain/done/4-15-task-006-compat-host-thinning-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 4-15 [done] task-006 compat host thinning 执行清单 v1 +# [recycle] 4-15 [done] task-006 compat host thinning 执行清单 v1 > 口径修正(2026-04-28):本文件的 `done` 状态只表示本批可验证范围内的 compat host 收薄完成;不表示 compat host 已退为纯 debug/internal,也不表示 `TreeShellIframeHost` 的 JS DOM/state machine 已从默认真实流量删除。最终退场门禁以 `4-18-tree-final-dom-shell-cutover-hard-gate-v1.md` 为准;该硬门禁现已在同目录 `done/` 收口。 > diff --git a/design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md b/design/old/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md similarity index 99% rename from design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md rename to design/old/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md index 364a0882..1b65b1e1 100644 --- a/design/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md +++ b/design/old/04-tree-domain/done/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md @@ -1,4 +1,4 @@ -# 4-16 [done] 树域 Rust 家族剩余 final runtime checklist v1 +# [recycle] 4-16 [done] 树域 Rust 家族剩余 final runtime checklist v1 > 创建时间:2026-04-27 > diff --git a/design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md b/design/old/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md similarity index 97% rename from design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md rename to design/old/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md index 813353ab..4a56919e 100644 --- a/design/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md +++ b/design/old/04-tree-domain/done/4-17-tree-3000-route-thin-proxy-boundary-v1.md @@ -1,4 +1,4 @@ -# 4-17 [done] tree 3000 route thin proxy boundary v1 +# [recycle] 4-17 [done] tree 3000 route thin proxy boundary v1 > 创建时间:2026-04-27 > diff --git a/design/04-tree-domain/done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md b/design/old/04-tree-domain/done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md similarity index 99% rename from design/04-tree-domain/done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md rename to design/old/04-tree-domain/done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md index ef87bc8f..a6cb169a 100644 --- a/design/04-tree-domain/done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md +++ b/design/old/04-tree-domain/done/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md @@ -1,4 +1,4 @@ -# 4-18 [done] 树域 final DOM shell 切换硬门禁 v1 +# [recycle] 4-18 [done] 树域 final DOM shell 切换硬门禁 v1 > 创建时间:2026-04-28 > diff --git a/design/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md b/design/old/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md similarity index 98% rename from design/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md rename to design/old/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md index 4eac5633..8d958cfa 100644 --- a/design/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md +++ b/design/old/04-tree-domain/done/4-19-tree-domain-final-status-and-rust-dom-shell-cutover-v1.md @@ -1,4 +1,4 @@ -# 4-19 [done] 树域 final status 与 Rust DOM shell 切换记录 v1 +# [recycle] 4-19 [done] 树域 final status 与 Rust DOM shell 切换记录 v1 > 更新时间:2026-04-28 > diff --git a/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md b/design/old/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md similarity index 99% rename from design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md rename to design/old/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md index 9d0a0208..9e913668 100644 --- a/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md +++ b/design/old/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md @@ -1,4 +1,4 @@ -# 4-21 [done] Local Folder / Convex 统一树源架构 v1 +# [recycle] 4-21 [done] Local Folder / Convex 统一树源架构 v1 > 更新时间:2026-05-06 > diff --git a/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md b/design/old/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md similarity index 99% rename from design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md rename to design/old/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md index 00ed7ef1..ba298f89 100644 --- a/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md +++ b/design/old/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 4-22 [done] Local Folder / Convex 统一树源顺序执行清单 v1 +# [recycle] 4-22 [done] Local Folder / Convex 统一树源顺序执行清单 v1 > 更新时间:2026-05-06 > diff --git a/design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md b/design/old/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md similarity index 99% rename from design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md rename to design/old/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md index 9e3fbbd2..00dc006e 100644 --- a/design/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md +++ b/design/old/04-tree-domain/done/4-35-convex-filetree-title-md-source-alignment-v1.md @@ -1,4 +1,4 @@ -# 4-35 [done] Convex File Tree 标题即 Markdown 文件名体系切换 v1 +# [recycle] 4-35 [done] Convex File Tree 标题即 Markdown 文件名体系切换 v1 > 更新时间:2026-05-15 > diff --git a/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md b/design/old/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md similarity index 98% rename from design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md rename to design/old/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md index c5d6db92..8caa5549 100644 --- a/design/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md +++ b/design/old/04-tree-domain/done/4-5-tree-command-envelope-cutover-stage1-v1.md @@ -1,4 +1,4 @@ -# 4-5 [done] Tree Command Envelope 第一批收口方案 v1 +# [recycle] 4-5 [done] Tree Command Envelope 第一批收口方案 v1 > 更新时间:2026-04-17 > diff --git a/design/04-tree-domain/done/4-8-tree-shell-cutover-performance-report-v1.md b/design/old/04-tree-domain/done/4-8-tree-shell-cutover-performance-report-v1.md similarity index 97% rename from design/04-tree-domain/done/4-8-tree-shell-cutover-performance-report-v1.md rename to design/old/04-tree-domain/done/4-8-tree-shell-cutover-performance-report-v1.md index fcb24afb..fbca1883 100644 --- a/design/04-tree-domain/done/4-8-tree-shell-cutover-performance-report-v1.md +++ b/design/old/04-tree-domain/done/4-8-tree-shell-cutover-performance-report-v1.md @@ -1,4 +1,4 @@ -# 4-8 [done] Tree Shell Cutover Performance Report v1 +# [recycle] 4-8 [done] Tree Shell Cutover Performance Report v1 > 更新时间:2026-04-18 > diff --git a/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md b/design/old/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md similarity index 99% rename from design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md rename to design/old/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md index c5e1cb11..2b1eb548 100644 --- a/design/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md +++ b/design/old/05-editor-mainline/done/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md @@ -1,4 +1,4 @@ -# 5-10 [done] Wolai 页面设置与 AI 交互壳对齐方案 v1 +# [recycle] 5-10 [done] Wolai 页面设置与 AI 交互壳对齐方案 v1 > 更新时间:2026-05-06 > diff --git a/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md b/design/old/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md similarity index 99% rename from design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md rename to design/old/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md index 09d8596c..0361b9e1 100644 --- a/design/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md +++ b/design/old/05-editor-mainline/done/5-11-wolai-page-settings-and-ai-surface-checklist-v1.md @@ -1,4 +1,4 @@ -# 5-11 [done] Wolai 页面设置与 AI 交互壳连续执行 checklist v1 +# [recycle] 5-11 [done] Wolai 页面设置与 AI 交互壳连续执行 checklist v1 > 更新时间:2026-05-06 > diff --git a/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md b/design/old/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md similarity index 99% rename from design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md rename to design/old/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md index 0fe60bf6..e9ad9aea 100644 --- a/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md +++ b/design/old/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md @@ -1,4 +1,4 @@ -# 5-13 [done] 页面块身份与命令合同 v1 +# [recycle] 5-13 [done] 页面块身份与命令合同 v1 > 更新时间:2026-05-16 > diff --git a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md b/design/old/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md similarity index 98% rename from design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md rename to design/old/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md index 1cb6bcab..a1eacae4 100644 --- a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md +++ b/design/old/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md @@ -1,4 +1,4 @@ -# 7-1 [done] Phase 7 文档页 AI 最小闭环执行清单 v1 +# [recycle] 7-1 [done] Phase 7 文档页 AI 最小闭环执行清单 v1 > 更新时间:2026-04-22 > diff --git a/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md b/design/old/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md similarity index 99% rename from design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md rename to design/old/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md index 56964b43..aa5f2f4b 100644 --- a/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md +++ b/design/old/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md @@ -1,4 +1,4 @@ -# 7-13 [process] 页面块编辑运行时 Actor 设计 v1 +# [recycle] 7-13 [process] 页面块编辑运行时 Actor 设计 v1 > 更新时间:2026-05-22 > diff --git a/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md b/design/old/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md similarity index 99% rename from design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md rename to design/old/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md index aae6adb6..f0c02aac 100644 --- a/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md +++ b/design/old/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md @@ -1,4 +1,4 @@ -# 7-15 [process] 页面 AI ACP Agent Runtime 统一抽象层 v1 +# [recycle] 7-15 [process] 页面 AI ACP Agent Runtime 统一抽象层 v1 > 创建时间:2026-05-17 > diff --git a/design/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md b/design/old/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md similarity index 99% rename from design/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md rename to design/old/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md index ae11d3c1..2e4715ff 100644 --- a/design/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md +++ b/design/old/07-ai/done/7-16-page-block-ai-real-smoke-followup-matrix-v1.md @@ -1,4 +1,4 @@ -# 7-16 [done] Page Block AI 真实页面 Smoke 后续矩阵 v1 +# [recycle] 7-16 [done] Page Block AI 真实页面 Smoke 后续矩阵 v1 > 创建时间:2026-05-18 > diff --git a/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md b/design/old/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md similarity index 99% rename from design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md rename to design/old/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md index 9d9ddf4d..28751795 100644 --- a/design/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md +++ b/design/old/07-ai/done/7-2-phase7-structured-artifact-write-chain-v1.md @@ -1,4 +1,4 @@ -# 7-2 [done] Phase 7 结构化 Artifact 写链最小落地方案 v1 +# [recycle] 7-2 [done] Phase 7 结构化 Artifact 写链最小落地方案 v1 > 更新时间:2026-04-23 > diff --git a/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md b/design/old/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md similarity index 99% rename from design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md rename to design/old/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md index 2d9fe843..64069b76 100644 --- a/design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md +++ b/design/old/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md @@ -1,4 +1,4 @@ -# 7-25 [process] ACP Session Runtime 增强规划 v1 +# [recycle] 7-25 [process] ACP Session Runtime 增强规划 v1 > 更新时间:2026-05-17 > 参考:`hermes-vscode-main` (ACP client) / `hermes-web-ui-0.5.18` (HTTP API) diff --git a/design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md b/design/old/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md similarity index 99% rename from design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md rename to design/old/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md index cd8a0756..c18b48dc 100644 --- a/design/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md +++ b/design/old/07-ai/done/7-27-online-markdown-writeback-final-content-truth-v2.md @@ -1,4 +1,4 @@ -# 7-27 [done] 在线 Markdown_edit 以最终 Markdown 为写回真源 v2 +# [recycle] 7-27 [done] 在线 Markdown_edit 以最终 Markdown 为写回真源 v2 > 更新:2026-05-18(v2:整合 CLI Main 参考实现分析,确认方向,补充见解) > diff --git a/design/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md b/design/old/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md similarity index 98% rename from design/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md rename to design/old/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md index 196bf417..3c83939e 100644 --- a/design/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md +++ b/design/old/07-ai/done/7-29-batch-i-ai-tool-final-content-acp-tail-closure-v1.md @@ -1,4 +1,4 @@ -# 7-29 [done] Batch I AI tool final-content / ACP tail 收口 checklist v1 +# [recycle] 7-29 [done] Batch I AI tool final-content / ACP tail 收口 checklist v1 > 创建时间:2026-05-21 > diff --git a/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md b/design/old/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md similarity index 99% rename from design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md rename to design/old/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md index c5a61069..e2f5fe76 100644 --- a/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md +++ b/design/old/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md @@ -1,4 +1,4 @@ -# 7-3 [done] 页面 AI Hermes 面板与 mnote Plugin 主线方案 v1 +# [recycle] 7-3 [done] 页面 AI Hermes 面板与 mnote Plugin 主线方案 v1 > 更新时间:2026-05-13 > diff --git a/design/07-ai/done/7-30-acp-session-load-resume-checklist-v1.md b/design/old/07-ai/done/7-30-acp-session-load-resume-checklist-v1.md similarity index 98% rename from design/07-ai/done/7-30-acp-session-load-resume-checklist-v1.md rename to design/old/07-ai/done/7-30-acp-session-load-resume-checklist-v1.md index 2dde3e93..02ca538d 100644 --- a/design/07-ai/done/7-30-acp-session-load-resume-checklist-v1.md +++ b/design/old/07-ai/done/7-30-acp-session-load-resume-checklist-v1.md @@ -1,4 +1,4 @@ -# 7-30 ACP session/load 恢复 checklist v1 +# [recycle] 7-30 ACP session/load 恢复 checklist v1 > 状态:done > diff --git a/design/07-ai/done/7-31-acp-permission-decision-loop-checklist-v1.md b/design/old/07-ai/done/7-31-acp-permission-decision-loop-checklist-v1.md similarity index 98% rename from design/07-ai/done/7-31-acp-permission-decision-loop-checklist-v1.md rename to design/old/07-ai/done/7-31-acp-permission-decision-loop-checklist-v1.md index 2c223807..956ff0a3 100644 --- a/design/07-ai/done/7-31-acp-permission-decision-loop-checklist-v1.md +++ b/design/old/07-ai/done/7-31-acp-permission-decision-loop-checklist-v1.md @@ -1,4 +1,4 @@ -# 7-31 ACP permission 决策闭环 checklist v1 +# [recycle] 7-31 ACP permission 决策闭环 checklist v1 > 状态:done > diff --git a/design/07-ai/done/7-32-acp-tool-location-and-open-action-checklist-v1.md b/design/old/07-ai/done/7-32-acp-tool-location-and-open-action-checklist-v1.md similarity index 98% rename from design/07-ai/done/7-32-acp-tool-location-and-open-action-checklist-v1.md rename to design/old/07-ai/done/7-32-acp-tool-location-and-open-action-checklist-v1.md index 932bf6a2..de938f85 100644 --- a/design/07-ai/done/7-32-acp-tool-location-and-open-action-checklist-v1.md +++ b/design/old/07-ai/done/7-32-acp-tool-location-and-open-action-checklist-v1.md @@ -1,4 +1,4 @@ -# 7-32 ACP tool locations 与打开动作 checklist v1 +# [recycle] 7-32 ACP tool locations 与打开动作 checklist v1 > 状态:done > diff --git a/design/07-ai/done/7-33-acp-session-info-plan-ui-checklist-v1.md b/design/old/07-ai/done/7-33-acp-session-info-plan-ui-checklist-v1.md similarity index 97% rename from design/07-ai/done/7-33-acp-session-info-plan-ui-checklist-v1.md rename to design/old/07-ai/done/7-33-acp-session-info-plan-ui-checklist-v1.md index 979746ab..32418366 100644 --- a/design/07-ai/done/7-33-acp-session-info-plan-ui-checklist-v1.md +++ b/design/old/07-ai/done/7-33-acp-session-info-plan-ui-checklist-v1.md @@ -1,4 +1,4 @@ -# 7-33 ACP session info / plan UI checklist v1 +# [recycle] 7-33 ACP session info / plan UI checklist v1 > 状态:done > diff --git a/design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md b/design/old/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md similarity index 98% rename from design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md rename to design/old/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md index 255b1e93..835b31dd 100644 --- a/design/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md +++ b/design/old/07-ai/done/7-34-acp-runtime-cleanup-availability-stability-tail-v1.md @@ -1,4 +1,4 @@ -# 7-34 [done] ACP runtime cleanup / availability / stability 尾项 checklist v1 +# [recycle] 7-34 [done] ACP runtime cleanup / availability / stability 尾项 checklist v1 > 创建时间:2026-05-21 > diff --git a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md b/design/old/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md similarity index 99% rename from design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md rename to design/old/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md index 00f19427..48793009 100644 --- a/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md +++ b/design/old/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 7-4 [done] 页面 AI Hermes 面板顺序执行 checklist v1 +# [recycle] 7-4 [done] 页面 AI Hermes 面板顺序执行 checklist v1 > 更新时间:2026-05-14 > diff --git a/design/07-ai/done/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md b/design/old/07-ai/done/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md similarity index 99% rename from design/07-ai/done/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md rename to design/old/07-ai/done/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md index c7dc8f88..0a0c40e9 100644 --- a/design/07-ai/done/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md +++ b/design/old/07-ai/done/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md @@ -1,4 +1,4 @@ -# 7-41 Page AI Hermes / Reasonix user profile isolation v2 +# [recycle] 7-41 Page AI Hermes / Reasonix user profile isolation v2 > 创建时间:2026-05-29 > diff --git a/design/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md b/design/old/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md similarity index 99% rename from design/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md rename to design/old/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md index 1dae3e84..db77d688 100644 --- a/design/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md +++ b/design/old/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md @@ -1,4 +1,4 @@ -# 7-44 ChatOnly Doubao session binding v1 +# [recycle] 7-44 ChatOnly Doubao session binding v1 > 创建时间:2026-05-31 > diff --git a/design/07-ai/done/7-45-chatonly-api-provider-runtime-v1.md b/design/old/07-ai/done/7-45-chatonly-api-provider-runtime-v1.md similarity index 99% rename from design/07-ai/done/7-45-chatonly-api-provider-runtime-v1.md rename to design/old/07-ai/done/7-45-chatonly-api-provider-runtime-v1.md index 119d0a5d..d11a3eeb 100644 --- a/design/07-ai/done/7-45-chatonly-api-provider-runtime-v1.md +++ b/design/old/07-ai/done/7-45-chatonly-api-provider-runtime-v1.md @@ -1,4 +1,4 @@ -# 7-45 ChatOnly API provider runtime v1 +# [recycle] 7-45 ChatOnly API provider runtime v1 > 创建时间:2026-06-02 > diff --git a/design/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md b/design/old/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md similarity index 99% rename from design/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md rename to design/old/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md index d9069cdb..2c7f102d 100644 --- a/design/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md +++ b/design/old/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md @@ -1,4 +1,4 @@ -# 7-58 Page AI Reasonix / Hermes 官方会话与响应对齐 v1 +# [recycle] 7-58 Page AI Reasonix / Hermes 官方会话与响应对齐 v1 > 创建时间:2026-06-09 > diff --git a/design/07-ai/done/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md b/design/old/07-ai/done/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md similarity index 99% rename from design/07-ai/done/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md rename to design/old/07-ai/done/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md index 1e654c36..98bdd799 100644 --- a/design/07-ai/done/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md +++ b/design/old/07-ai/done/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md @@ -1,4 +1,4 @@ -# 7-59 Page AI Reasonix Desktop / Hermes Web 控制面借鉴设计 v1 +# [recycle] 7-59 Page AI Reasonix Desktop / Hermes Web 控制面借鉴设计 v1 > 创建时间:2026-06-10 > diff --git a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md b/design/old/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md similarity index 99% rename from design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md rename to design/old/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md index e374252e..8055aec8 100644 --- a/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md +++ b/design/old/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md @@ -1,4 +1,4 @@ -# 7-6 [done] mnote Hermes plugin tool 合同 v1 +# [recycle] 7-6 [done] mnote Hermes plugin tool 合同 v1 > 更新时间:2026-05-14 > diff --git a/design/07-ai/done/7-60-page-ai-settings-ia-cleanup-v1.md b/design/old/07-ai/done/7-60-page-ai-settings-ia-cleanup-v1.md similarity index 99% rename from design/07-ai/done/7-60-page-ai-settings-ia-cleanup-v1.md rename to design/old/07-ai/done/7-60-page-ai-settings-ia-cleanup-v1.md index dbf2a901..91682840 100644 --- a/design/07-ai/done/7-60-page-ai-settings-ia-cleanup-v1.md +++ b/design/old/07-ai/done/7-60-page-ai-settings-ia-cleanup-v1.md @@ -1,4 +1,4 @@ -# 7-60 Page AI 设置 IA 整理 v1 +# [recycle] 7-60 Page AI 设置 IA 整理 v1 > 创建时间:2026-06-10 > diff --git a/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md b/design/old/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md similarity index 99% rename from design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md rename to design/old/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md index 3a1602c0..e7dac2e0 100644 --- a/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md +++ b/design/old/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md @@ -1,4 +1,4 @@ -# 7-7 [done] 页面 AI Mini Hermes 控制面设计与执行 checklist v1 +# [recycle] 7-7 [done] 页面 AI Mini Hermes 控制面设计与执行 checklist v1 > 更新时间:2026-05-14 > diff --git a/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md b/design/old/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md similarity index 99% rename from design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md rename to design/old/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md index 01ed2a4a..174324d2 100644 --- a/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md +++ b/design/old/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md @@ -1,4 +1,4 @@ -# 7-9 [done] 页面/块 AI 工具体系规划 v1 +# [recycle] 7-9 [done] 页面/块 AI 工具体系规划 v1 > 更新时间:2026-05-16 > diff --git a/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md b/design/old/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md similarity index 99% rename from design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md rename to design/old/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md index f9c501a1..72ad3d16 100644 --- a/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md +++ b/design/old/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md @@ -1,4 +1,4 @@ -# 7-47 MNote 公共 capability/plugin 注册表设计 v1 +# [recycle] 7-47 MNote 公共 capability/plugin 注册表设计 v1 > 2026-06-28 覆盖说明:本文的 Hermes / Reasonix / LightRAG 能力注册口径已退为 legacy 参考。当前能力模型应围绕 OpenHub / opencode / WeKnora,旧 `mnote-knowledge-rag` 与 `mnote_lightrag_bridge` 仅保留兼容映射和迁移对照。 diff --git a/design/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md b/design/old/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md similarity index 99% rename from design/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md rename to design/old/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md index 6e18ceb7..7b975cc8 100644 --- a/design/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md +++ b/design/old/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md @@ -1,4 +1,4 @@ -# 7-61 [process] Yuxi reference: middleware tool composition, extensions entry, knowledge settings UI, and local dashboard v1 +# [recycle] 7-61 [process] Yuxi reference: middleware tool composition, extensions entry, knowledge settings UI, and local dashboard v1 > 2026-06-28 覆盖说明:本文中 Hermes / LightRAG 相关实现口径已部分退为 legacy。仍可参考 Yuxi 的 middleware、extensions 入口和 dashboard 信息架构,但当前 MNote 知识库 / agent 主线以 OpenHub / opencode / WeKnora 为准。 diff --git a/design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md b/design/old/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md similarity index 99% rename from design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md rename to design/old/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md index 6d5832d2..cf142781 100644 --- a/design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md +++ b/design/old/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md @@ -1,6 +1,6 @@ > 状态补充(2026-07-03):本稿冻结为 opencode runtime / 官方 opencode WebUI iframe fallback 参考;Page AI 产品主线为 OpenHub / native agent + LightRAG + Turso/libSQL。官方 iframe 仅在 OpenHub AI 面板不可用、调试 opencode 原生行为或做回归对照时启用,不再作为默认产品路径。此前指向 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 的 WeKnora 默认 provider 口径已标记 stale。 -# 7-65 [process] Page AI opencode WebUI embed v1 +# [recycle] 7-65 [process] Page AI opencode WebUI embed v1 > 创建时间:2026-06-23 > diff --git a/design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md b/design/old/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md similarity index 98% rename from design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md rename to design/old/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md index fb7afe40..b9bde299 100644 --- a/design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md +++ b/design/old/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md @@ -1,4 +1,4 @@ -# 7-66 [process] Page AI opencode-native UI v1 +# [recycle] 7-66 [process] Page AI opencode-native UI v1 > 状态补充(2026-07-03):本稿冻结为 native UI fallback / debug receipt 参考;Page AI 产品主线为 OpenHub / native agent + LightRAG + Turso/libSQL。不得继续扩写 MNote 自研聊天框作为默认 Page AI UI。此前指向 `7-68-openhub-weknora-mnote-deep-fusion-v1.md` 的 WeKnora 默认 provider 口径已标记 stale。 diff --git a/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md b/design/old/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md similarity index 99% rename from design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md rename to design/old/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md index 7cae2619..46a72d7a 100644 --- a/design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md +++ b/design/old/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md @@ -1,4 +1,4 @@ -# 7-12 [reference] 页面 AI Hermes 工具路由与编辑审阅面设计 v1 +# [recycle] 7-12 [reference] 页面 AI Hermes 工具路由与编辑审阅面设计 v1 > 更新时间:2026-05-19 > diff --git a/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md b/design/old/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md similarity index 99% rename from design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md rename to design/old/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md index 2c0005ec..c4a8d135 100644 --- a/design/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md +++ b/design/old/07-ai/reference/7-17-acp-session-convex-sharing-contract-v1.md @@ -1,4 +1,4 @@ -# 7-17 [reference] ACP Session 与控制面账号作用域 / 分享合同 v1 +# [recycle] 7-17 [reference] ACP Session 与控制面账号作用域 / 分享合同 v1 > 创建时间:2026-05-18 > diff --git a/design/07-ai/reference/7-35-reasonix-browser-test-contract-v1.md b/design/old/07-ai/reference/7-35-reasonix-browser-test-contract-v1.md similarity index 99% rename from design/07-ai/reference/7-35-reasonix-browser-test-contract-v1.md rename to design/old/07-ai/reference/7-35-reasonix-browser-test-contract-v1.md index 28e98296..ae02a495 100644 --- a/design/07-ai/reference/7-35-reasonix-browser-test-contract-v1.md +++ b/design/old/07-ai/reference/7-35-reasonix-browser-test-contract-v1.md @@ -1,4 +1,4 @@ -# Reasonix Browser Test Contract v1 +# [recycle] Reasonix Browser Test Contract v1 ## 状态 diff --git a/design/07-ai/reference/7-36-reasonix-skill-maintainer-v1.md b/design/old/07-ai/reference/7-36-reasonix-skill-maintainer-v1.md similarity index 99% rename from design/07-ai/reference/7-36-reasonix-skill-maintainer-v1.md rename to design/old/07-ai/reference/7-36-reasonix-skill-maintainer-v1.md index 97d0ed70..6d8a0d6c 100644 --- a/design/07-ai/reference/7-36-reasonix-skill-maintainer-v1.md +++ b/design/old/07-ai/reference/7-36-reasonix-skill-maintainer-v1.md @@ -1,4 +1,4 @@ -# Reasonix Skill Maintainer v1 +# [recycle] Reasonix Skill Maintainer v1 ## 状态 diff --git a/design/07-ai/reference/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md b/design/old/07-ai/reference/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md similarity index 99% rename from design/07-ai/reference/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md rename to design/old/07-ai/reference/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md index 04e22bb3..aa99f900 100644 --- a/design/07-ai/reference/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md +++ b/design/old/07-ai/reference/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md @@ -1,4 +1,4 @@ -# 7-37 Claude Code / Reasonix Worker 评估协议:0524 Bug2 v1 +# [recycle] 7-37 Claude Code / Reasonix Worker 评估协议:0524 Bug2 v1 ## 目标 diff --git a/design/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md b/design/old/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md similarity index 99% rename from design/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md rename to design/old/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md index f2eeeddf..054537f7 100644 --- a/design/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md +++ b/design/old/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md @@ -1,4 +1,4 @@ -# 7-68 WeKnora 知识库页面 UI 参考 +# [recycle] 7-68 WeKnora 知识库页面 UI 参考 状态:reference 范围:只记录 WeKnora 知识库页面可嫁接的页面结构、状态与 MNote 字段映射;不修改 MNote runtime,不修改 WeKnora 源码。 diff --git a/design/10-review/done/01-rust-kernel-web-review.md b/design/old/10-review/done/01-rust-kernel-web-review.md similarity index 99% rename from design/10-review/done/01-rust-kernel-web-review.md rename to design/old/10-review/done/01-rust-kernel-web-review.md index 9c84771f..a1987ed3 100644 --- a/design/10-review/done/01-rust-kernel-web-review.md +++ b/design/old/10-review/done/01-rust-kernel-web-review.md @@ -1,4 +1,4 @@ -# Rust Kernel / Web 实现偏差审查 +# [recycle] Rust Kernel / Web 实现偏差审查 > 执行状态:已归档到 `design/10-review/done/`。本文保留为历史审查快照,当前活跃口径以 `./06-execution-checklist-and-acceptance.md` 为准。 diff --git a/design/10-review/done/02-frontend-editor-tree-review.md b/design/old/10-review/done/02-frontend-editor-tree-review.md similarity index 99% rename from design/10-review/done/02-frontend-editor-tree-review.md rename to design/old/10-review/done/02-frontend-editor-tree-review.md index 1afe3f1a..1a7d580e 100644 --- a/design/10-review/done/02-frontend-editor-tree-review.md +++ b/design/old/10-review/done/02-frontend-editor-tree-review.md @@ -1,4 +1,4 @@ -# 前端编辑器 / 树体验实现偏差审查 +# [recycle] 前端编辑器 / 树体验实现偏差审查 > 执行状态:已归档到 `design/10-review/done/`。本文保留为历史审查快照,当前活跃口径以 `./06-execution-checklist-and-acceptance.md` 为准。 diff --git a/design/10-review/done/03-convex-realtime-storage-review.md b/design/old/10-review/done/03-convex-realtime-storage-review.md similarity index 99% rename from design/10-review/done/03-convex-realtime-storage-review.md rename to design/old/10-review/done/03-convex-realtime-storage-review.md index 7310d0db..f654b578 100644 --- a/design/10-review/done/03-convex-realtime-storage-review.md +++ b/design/old/10-review/done/03-convex-realtime-storage-review.md @@ -1,4 +1,4 @@ -# Convex / Realtime / Storage 实现偏差审查 +# [recycle] Convex / Realtime / Storage 实现偏差审查 > 执行状态:已归档到 `design/10-review/done/`。本文保留为历史审查快照,当前活跃口径以 `./06-execution-checklist-and-acceptance.md` 为准。 diff --git a/design/10-review/done/04-secondary-domains-and-design-governance-review.md b/design/old/10-review/done/04-secondary-domains-and-design-governance-review.md similarity index 99% rename from design/10-review/done/04-secondary-domains-and-design-governance-review.md rename to design/old/10-review/done/04-secondary-domains-and-design-governance-review.md index 243b8e43..c509956c 100644 --- a/design/10-review/done/04-secondary-domains-and-design-governance-review.md +++ b/design/old/10-review/done/04-secondary-domains-and-design-governance-review.md @@ -1,4 +1,4 @@ -# 次级域与设计治理实现偏差审查 +# [recycle] 次级域与设计治理实现偏差审查 > 执行状态:已归档到 `design/10-review/done/`。本文保留为历史审查快照,当前活跃口径以 `./06-execution-checklist-and-acceptance.md` 为准。 diff --git a/design/10-review/done/05-tree.md b/design/old/10-review/done/05-tree.md similarity index 92% rename from design/10-review/done/05-tree.md rename to design/old/10-review/done/05-tree.md index b94cde02..2b3a05ab 100644 --- a/design/10-review/done/05-tree.md +++ b/design/old/10-review/done/05-tree.md @@ -1,4 +1,6 @@ -> 执行状态:本文已归档到 `design/10-review/done/`,是 10-review 阶段的树域判断历史记录,不再作为待办清单直接执行。Resource Tree / File Tree / Page Tree 真源合同已由 `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` 承接;主编辑区 Object Tab、mindmap object editor、`index.md` 隔离已由 `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md` 承接。后续只需按 `06-execution-checklist-and-acceptance.md` 保留 smoke 防回归。 +# [recycle] 05 Tree 历史审查 + +> 执行状态:本文已归档到 `design/old/10-review/done/`,是 10-review 阶段的树域判断历史记录,不再作为待办清单直接执行。Resource Tree / File Tree / Page Tree 真源合同已由 `/mnt/Data1T/mnote/design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` 承接;主编辑区 Object Tab、mindmap object editor、`index.md` 隔离已由 `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-12-main-editor-object-tab-resource-alignment-checklist-v1.md` 承接。后续只需按 `06-execution-checklist-and-acceptance.md` 保留 smoke 防回归。 结论 我基本同意你的方向,但要把“文件树为根源”说得更精确:不应让“文件树 UI 组件”成为真源,而应让 Rust kernel 中的 workspace resource tree / file tree projection 背后 diff --git a/design/10-review/done/06-execution-checklist-and-acceptance.md b/design/old/10-review/done/06-execution-checklist-and-acceptance.md similarity index 99% rename from design/10-review/done/06-execution-checklist-and-acceptance.md rename to design/old/10-review/done/06-execution-checklist-and-acceptance.md index 28201328..f397f3a2 100644 --- a/design/10-review/done/06-execution-checklist-and-acceptance.md +++ b/design/old/10-review/done/06-execution-checklist-and-acceptance.md @@ -1,4 +1,4 @@ -# 10-review 顺序执行清单与验收标准 +# [recycle] 10-review 顺序执行清单与验收标准 > 执行状态:已完成并归档到 `design/10-review/done/`。本文是本轮 10-review 的最终验收依据与防回归口径,根目录 `design/10-review/README.md` 仅保留索引。 diff --git a/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md b/design/old/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md similarity index 99% rename from design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md rename to design/old/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md index d43fe876..97a008dd 100644 --- a/design/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md +++ b/design/old/10-review/done/08-kernel-architecture-next-priority-review-and-checklist.md @@ -1,4 +1,4 @@ -# 08 [done] Kernel 架构收口与下一阶段优先级 Review / Checklist v1 +# [recycle] 08 [done] Kernel 架构收口与下一阶段优先级 Review / Checklist v1 > 更新时间:2026-05-18 > diff --git a/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md b/design/old/10-review/done/09-page-ai-fast-block-edit-runtime-review.md similarity index 99% rename from design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md rename to design/old/10-review/done/09-page-ai-fast-block-edit-runtime-review.md index 2798a67c..8878259c 100644 --- a/design/10-review/done/09-page-ai-fast-block-edit-runtime-review.md +++ b/design/old/10-review/done/09-page-ai-fast-block-edit-runtime-review.md @@ -1,4 +1,4 @@ -# 09 页面 AI 快速块编辑 Runtime Review +# [recycle] 09 页面 AI 快速块编辑 Runtime Review > 状态:`done` > diff --git a/design/10-review/done/10-current-mnote-ai-runtime-review-v1.md b/design/old/10-review/done/10-current-mnote-ai-runtime-review-v1.md similarity index 98% rename from design/10-review/done/10-current-mnote-ai-runtime-review-v1.md rename to design/old/10-review/done/10-current-mnote-ai-runtime-review-v1.md index a7db6c1f..5b1f76d4 100644 --- a/design/10-review/done/10-current-mnote-ai-runtime-review-v1.md +++ b/design/old/10-review/done/10-current-mnote-ai-runtime-review-v1.md @@ -1,4 +1,4 @@ -# 10 [done] 当前 mnote 项目 AI / Page Aggregate 定向 Review v1 +# [recycle] 10 [done] 当前 mnote 项目 AI / Page Aggregate 定向 Review v1 > 更新时间:2026-05-17 > diff --git a/design/10-review/done/11-current-full-architecture-review-v1.md b/design/old/10-review/done/11-current-full-architecture-review-v1.md similarity index 98% rename from design/10-review/done/11-current-full-architecture-review-v1.md rename to design/old/10-review/done/11-current-full-architecture-review-v1.md index e25ecd39..ee4dcfc7 100644 --- a/design/10-review/done/11-current-full-architecture-review-v1.md +++ b/design/old/10-review/done/11-current-full-architecture-review-v1.md @@ -1,4 +1,4 @@ -# 11 [done] 当前完整架构 Review v1 +# [recycle] 11 [done] 当前完整架构 Review v1 > 更新时间:2026-05-17 > diff --git a/design/10-review/done/15-design-governance-mvp-post-review-v1.md b/design/old/10-review/done/15-design-governance-mvp-post-review-v1.md similarity index 99% rename from design/10-review/done/15-design-governance-mvp-post-review-v1.md rename to design/old/10-review/done/15-design-governance-mvp-post-review-v1.md index 4d01474d..1adcf742 100644 --- a/design/10-review/done/15-design-governance-mvp-post-review-v1.md +++ b/design/old/10-review/done/15-design-governance-mvp-post-review-v1.md @@ -1,4 +1,4 @@ -# 15 Design Governance MVP 后阶段 Review v1 +# [recycle] 15 Design Governance MVP 后阶段 Review v1 > 状态:done > diff --git a/design/10-review/done/17-sidex-mnote-workbench-gap-execution-checklist-v1.md b/design/old/10-review/done/17-sidex-mnote-workbench-gap-execution-checklist-v1.md similarity index 99% rename from design/10-review/done/17-sidex-mnote-workbench-gap-execution-checklist-v1.md rename to design/old/10-review/done/17-sidex-mnote-workbench-gap-execution-checklist-v1.md index a77361e4..a8a24d97 100644 --- a/design/10-review/done/17-sidex-mnote-workbench-gap-execution-checklist-v1.md +++ b/design/old/10-review/done/17-sidex-mnote-workbench-gap-execution-checklist-v1.md @@ -1,4 +1,4 @@ -# 17 Sidex / MNote Workbench Gap Execution Checklist v1 +# [recycle] 17 Sidex / MNote Workbench Gap Execution Checklist v1 > 状态:done > diff --git a/design/10-review/done/18-current-design-and-bug-hunt-review-v1.md b/design/old/10-review/done/18-current-design-and-bug-hunt-review-v1.md similarity index 99% rename from design/10-review/done/18-current-design-and-bug-hunt-review-v1.md rename to design/old/10-review/done/18-current-design-and-bug-hunt-review-v1.md index bf0f84f4..16bda872 100644 --- a/design/10-review/done/18-current-design-and-bug-hunt-review-v1.md +++ b/design/old/10-review/done/18-current-design-and-bug-hunt-review-v1.md @@ -1,4 +1,4 @@ -# 18 当前 design 口径与 bug 寻找 Review v1 +# [recycle] 18 当前 design 口径与 bug 寻找 Review v1 > 创建时间:2026-05-31 > diff --git a/design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md b/design/old/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md similarity index 98% rename from design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md rename to design/old/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md index 16ebe9e1..bd3c5e20 100644 --- a/design/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md +++ b/design/old/10-review/done/19-non-lightrag-design-cleanup-followup-checklist-v1.md @@ -1,4 +1,4 @@ -# 19 Non-LightRAG design cleanup and follow-up checklist v1 +# [recycle] 19 Non-LightRAG design cleanup and follow-up checklist v1 > 创建时间:2026-06-06 > diff --git a/harness-init.sh b/harness-init.sh deleted file mode 100644 index 5cf8b68e..00000000 --- a/harness-init.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Harness 会在每次会话启动时执行此脚本,这里只做轻量且可重复执行的环境检查。 -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$ROOT_DIR" - -command -v git >/dev/null || { - echo "ERROR: 缺少 git" - exit 1 -} - -command -v python3 >/dev/null || { - echo "ERROR: 缺少 python3" - exit 1 -} - -if [ -f "$ROOT_DIR/package.json" ] && ! command -v node >/dev/null; then - echo "WARN: 缺少 node,脚本任务可能无法执行" -fi - -echo "Harness 环境检查完成" diff --git a/package-lock.json b/package-lock.json index 4fa54479..18a03a7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,8 @@ "pptx-preview": "^1.0.7" }, "devDependencies": { - "@playwright/test": "^1.56.1", - "playwright": "^1.56.1" + "@playwright/test": "1.61.1", + "playwright": "1.61.1" } }, "node_modules/@e965/xlsx": { @@ -281,13 +281,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -406,13 +406,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -425,9 +425,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 533d45f6..a60053dd 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "prod:start": "node scripts/prod-build-start.js" }, "devDependencies": { - "@playwright/test": "^1.56.1", - "playwright": "^1.56.1" + "@playwright/test": "1.61.1", + "playwright": "1.61.1" }, "dependencies": { "@e965/xlsx": "0.20.3", diff --git a/playwright-snapshot-auth.md b/playwright-snapshot-auth.md deleted file mode 100644 index 19ec28d6..00000000 --- a/playwright-snapshot-auth.md +++ /dev/null @@ -1 +0,0 @@ -- generic [ref=e2] [box=0,13,1440,39]: "{\"ok\":false,\"code\":\"convex_upstream_error\",\"message\":\"Convex mutation 失败\",\"requestId\":\"req_1778542522227_65\",\"traceId\":\"trace_1778542522227_66\"}" \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41d0d42e..1854ba66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,13 +17,16 @@ importers: pdfjs-dist: specifier: 4.10.38 version: 4.10.38 + pptx-preview: + specifier: ^1.0.7 + version: 1.0.7 devDependencies: '@playwright/test': - specifier: ^1.56.1 - version: 1.57.0 + specifier: 1.61.1 + version: 1.61.1 playwright: - specifier: ^1.56.1 - version: 1.57.0 + specifier: 1.61.1 + version: 1.61.1 packages: @@ -61,30 +64,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.100': resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.100': resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.100': resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.100': resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} @@ -102,8 +110,8 @@ packages: resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} engines: {node: '>= 10'} - '@playwright/test@1.57.0': - resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} engines: {node: '>=18'} hasBin: true @@ -113,6 +121,9 @@ packages: docx-preview@0.3.7: resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==} + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -133,6 +144,9 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -140,16 +154,19 @@ packages: resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} engines: {node: '>=20'} - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} engines: {node: '>=18'} hasBin: true + pptx-preview@1.0.7: + resolution: {integrity: sha512-YByocJuyxAR4YB4Q3+VAxdLfEvA5LojG1gAJsx2Mw0QU5FJPps/2fkJOupJ6oBbA+KdWRpuAk6G6T34rKCHVxw==} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -165,9 +182,23 @@ packages: string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + snapshots: '@e965/xlsx@0.20.3': {} @@ -220,9 +251,9 @@ snapshots: '@napi-rs/canvas-win32-x64-msvc': 0.1.100 optional: true - '@playwright/test@1.57.0': + '@playwright/test@1.61.1': dependencies: - playwright: 1.57.0 + playwright: 1.61.1 core-util-is@1.0.3: {} @@ -230,6 +261,11 @@ snapshots: dependencies: jszip: 3.10.1 + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + fsevents@2.3.2: optional: true @@ -250,20 +286,30 @@ snapshots: dependencies: immediate: 3.0.6 + lodash@4.18.1: {} + pako@1.0.11: {} pdfjs-dist@4.10.38: optionalDependencies: '@napi-rs/canvas': 0.1.100 - playwright-core@1.57.0: {} + playwright-core@1.61.1: {} - playwright@1.57.0: + playwright@1.61.1: dependencies: - playwright-core: 1.57.0 + playwright-core: 1.61.1 optionalDependencies: fsevents: 2.3.2 + pptx-preview@1.0.7: + dependencies: + echarts: 5.6.0 + jszip: 3.10.1 + lodash: 4.18.1 + tslib: 2.8.1 + uuid: 10.0.0 + process-nextick-args@2.0.1: {} readable-stream@2.3.8: @@ -284,4 +330,14 @@ snapshots: dependencies: safe-buffer: 5.1.2 + tslib@2.3.0: {} + + tslib@2.8.1: {} + util-deprecate@1.0.2: {} + + uuid@10.0.0: {} + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/reasonix.toml b/reasonix.toml deleted file mode 100644 index 95166b05..00000000 --- a/reasonix.toml +++ /dev/null @@ -1,82 +0,0 @@ -# Reasonix configuration. -# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults. -# Secrets come from the environment via api_key_env; never put keys here. - -config_version = 2 # schema marker for diagnostics; old versions may ignore it -default_model = "deepseek-flash" -# language = "zh" # ui/model language; empty = auto-detect from $LANG / $REASONIX_LANG - -[agent] -# system_prompt = """...""" # omit to use the built-in prompt for this version -# system_prompt_file = "prompts/system.md" # overrides system_prompt when set -max_steps = 0 # executor tool-call rounds; 0 = no limit -planner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit -temperature = 0.0 -auto_plan = "off" # off|on; off keeps plan mode manual -# auto_plan_classifier = "deepseek-flash" # optional; only used for borderline tasks -soft_compact_ratio = 0.5 # notice only; keeps cache-first prefix intact -compact_ratio = 0.8 # try compacting when prompt reaches this fraction -compact_force_ratio = 0.9 # force compacting at this high-water mark -# planner_model = "mimo" # optional: enable two-model collaboration -# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills -# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } # per-skill overrides -# subagent_effort = "high" # optional default effort for subagents -# subagent_efforts = { review = "max", task = "high" } # per-tool/skill effort overrides -# output_style = "explanatory" # explanatory | learning | concise | custom; empty = default - -[tools] -enabled = [] # empty = all built-in tools -bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap - -[codegraph] -enabled = true # built-in MCP server; off by default for first-run sessions -auto_install = true # fetch the runtime when CodeGraph is enabled but missing -# path = "" # empty = cache, then PATH, then a bundle beside reasonix - -[lsp] -enabled = true # language server tools; servers launch lazily when used -# [lsp.servers.go] -# command = "gopls" -# args = [] -# extensions = [".go"] - -[skills] -# paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots -# excluded_paths = ["~/.agents/skills"] # hide convention roots without deleting folders -# max_depth = 3 # nested scan depth; set 1 for legacy root-only discovery -# disabled_skills = ["review"] # hide noisy or unwanted skills - -[permissions] -# Per-call gating. mode = writer fallback when no rule matches: ask|allow|deny. -# Readers always default to allow. Precedence: deny > ask > allow > fallback. -# Rules are "Tool" or "Tool(specifier)"; e.g. Bash(go test:*), Edit(src/**). -mode = "ask" -# deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode -allow = ["mcp__lightrag_native__get_health"] -# ask = ["Edit(src/**)"] # force a prompt even if otherwise allowed - -[sandbox] -# Confine tool blast radius. File-writers (write_file/edit_file/multi_edit) -# may only write under workspace_root (empty = current dir) + allow_write. -# bash = "enforce" (default) jails each command in an OS sandbox (macOS now; -# graceful fallback elsewhere); "off" disables it. network allows egress. -# workspace_root = "" # default: current working directory -# allow_write = ["/tmp"] # extra dirs writers may also modify -bash = "enforce" -network = true - -[statusline] -# A custom status line: a command whose first stdout line replaces the built-in -# data row. It receives {"model","contextUsed","contextWindow","cwd"} as JSON on stdin. -# command = "my-statusline.sh" - -# External MCP servers. type: "stdio" (default, a subprocess) | "http" | "sse". -# ${VAR} / ${VAR:-default} are expanded from the environment in command/args/env/url/headers. -# [[plugins]] -# name = "example" -# command = "reasonix-plugin-example" -# [[plugins]] # a remote server over Streamable HTTP -# name = "stripe" -# type = "http" -# url = "https://mcp.stripe.com" -# headers = { Authorization = "Bearer ${STRIPE_KEY}" } diff --git a/recycle/20260522-convex-runtime-retirement/convex/_generated/api.d.ts b/recycle/20260522-convex-runtime-retirement/convex/_generated/api.d.ts deleted file mode 100644 index 4dac351d..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/_generated/api.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type * as aiSessions from "../aiSessions.js"; -import type * as auth from "../auth.js"; -import type * as users from "../users.js"; - -import type { - ApiFromModules, - FilterApi, - FunctionReference, -} from "convex/server"; - -declare const fullApi: ApiFromModules<{ - aiSessions: typeof aiSessions; - auth: typeof auth; - users: typeof users; -}>; - -/** - * A utility for referencing Convex functions in your app's public API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export declare const api: FilterApi< - typeof fullApi, - FunctionReference ->; - -/** - * A utility for referencing Convex functions in your app's internal API. - * - * Usage: - * ```js - * const myFunctionReference = internal.myModule.myFunction; - * ``` - */ -export declare const internal: FilterApi< - typeof fullApi, - FunctionReference ->; - -export declare const components: {}; diff --git a/recycle/20260522-convex-runtime-retirement/convex/_generated/api.js b/recycle/20260522-convex-runtime-retirement/convex/_generated/api.js deleted file mode 100644 index 44bf9858..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/_generated/api.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { anyApi, componentsGeneric } from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export const api = anyApi; -export const internal = anyApi; -export const components = componentsGeneric(); diff --git a/recycle/20260522-convex-runtime-retirement/convex/_generated/dataModel.d.ts b/recycle/20260522-convex-runtime-retirement/convex/_generated/dataModel.d.ts deleted file mode 100644 index f97fd194..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/_generated/dataModel.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable */ -/** - * Generated data model types. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames, -} from "convex/server"; -import type { GenericId } from "convex/values"; -import schema from "../schema.js"; - -/** - * The names of all of your Convex tables. - */ -export type TableNames = TableNamesInDataModel; - -/** - * The type of a document stored in Convex. - * - * @typeParam TableName - A string literal type of the table name (like "users"). - */ -export type Doc = DocumentByName< - DataModel, - TableName ->; - -/** - * An identifier for a document in Convex. - * - * Convex documents are uniquely identified by their `Id`, which is accessible - * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). - * - * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. - * - * IDs are just strings at runtime, but this type can be used to distinguish them from other - * strings when type checking. - * - * @typeParam TableName - A string literal type of the table name (like "users"). - */ -export type Id = - GenericId; - -/** - * A type describing your Convex data model. - * - * This type includes information about what tables you have, the type of - * documents stored in those tables, and the indexes defined on them. - * - * This type is used to parameterize methods like `queryGeneric` and - * `mutationGeneric` to make them type-safe. - */ -export type DataModel = DataModelFromSchemaDefinition; diff --git a/recycle/20260522-convex-runtime-retirement/convex/_generated/server.d.ts b/recycle/20260522-convex-runtime-retirement/convex/_generated/server.d.ts deleted file mode 100644 index bec05e68..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/_generated/server.d.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - ActionBuilder, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter, -} from "convex/server"; -import type { DataModel } from "./dataModel.js"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export declare const query: QueryBuilder; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export declare const internalQuery: QueryBuilder; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export declare const mutation: MutationBuilder; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export declare const internalMutation: MutationBuilder; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @param func - The action. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped action. Include this as an `export` to name it and make it accessible. - */ -export declare const action: ActionBuilder; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Include this as an `export` to name it and make it accessible. - */ -export declare const internalAction: ActionBuilder; - -/** - * Define an HTTP action. - * - * The wrapped function will be used to respond to HTTP requests received - * by a Convex deployment if the requests matches the path and method where - * this action is routed. Be sure to route your httpAction in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument - * and a Fetch API `Request` object as its second. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export declare const httpAction: HttpActionBuilder; - -/** - * A set of services for use within Convex query functions. - * - * The query context is passed as the first argument to any Convex query - * function run on the server. - * - * This differs from the {@link MutationCtx} because all of the services are - * read-only. - */ -export type QueryCtx = GenericQueryCtx; - -/** - * A set of services for use within Convex mutation functions. - * - * The mutation context is passed as the first argument to any Convex mutation - * function run on the server. - */ -export type MutationCtx = GenericMutationCtx; - -/** - * A set of services for use within Convex action functions. - * - * The action context is passed as the first argument to any Convex action - * function run on the server. - */ -export type ActionCtx = GenericActionCtx; - -/** - * An interface to read from the database within Convex query functions. - * - * The two entry points are {@link DatabaseReader.get}, which fetches a single - * document by its {@link Id}, or {@link DatabaseReader.query}, which starts - * building a query. - */ -export type DatabaseReader = GenericDatabaseReader; - -/** - * An interface to read from and write to the database within Convex mutation - * functions. - * - * Convex guarantees that all writes within a single mutation are - * executed atomically, so you never have to worry about partial writes leaving - * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) - * for the guarantees Convex provides your functions. - */ -export type DatabaseWriter = GenericDatabaseWriter; diff --git a/recycle/20260522-convex-runtime-retirement/convex/_generated/server.js b/recycle/20260522-convex-runtime-retirement/convex/_generated/server.js deleted file mode 100644 index bf3d25ad..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/_generated/server.js +++ /dev/null @@ -1,93 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, -} from "convex/server"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const query = queryGeneric; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const internalQuery = internalQueryGeneric; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const mutation = mutationGeneric; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const internalMutation = internalMutationGeneric; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @param func - The action. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped action. Include this as an `export` to name it and make it accessible. - */ -export const action = actionGeneric; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Include this as an `export` to name it and make it accessible. - */ -export const internalAction = internalActionGeneric; - -/** - * Define an HTTP action. - * - * The wrapped function will be used to respond to HTTP requests received - * by a Convex deployment if the requests matches the path and method where - * this action is routed. Be sure to route your httpAction in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument - * and a Fetch API `Request` object as its second. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export const httpAction = httpActionGeneric; diff --git a/recycle/20260522-convex-runtime-retirement/convex/aiSessions.ts b/recycle/20260522-convex-runtime-retirement/convex/aiSessions.ts deleted file mode 100644 index ba216b6e..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/aiSessions.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { mutation, query } from "./_generated/server"; -import { v } from "convex/values"; - -const runtimeRunArgs = { - schema: v.literal("mnote.acp_runtime_run.v1"), - source: v.literal("acp"), - userId: v.string(), - workspaceId: v.optional(v.union(v.string(), v.null())), - documentId: v.string(), - sessionId: v.string(), - runId: v.string(), - title: v.optional(v.union(v.string(), v.null())), - profile: v.string(), - acpRuntime: v.string(), - traceId: v.string(), - status: v.string(), - runtime: v.any(), - payload: v.any(), - retention: v.any(), - createdAt: v.number(), - updatedAt: v.number(), -}; - -const runtimeEventArgs = { - schema: v.literal("mnote.acp_runtime_event.v1"), - source: v.literal("acp"), - eventId: v.string(), - userId: v.string(), - workspaceId: v.optional(v.union(v.string(), v.null())), - documentId: v.string(), - sessionId: v.string(), - runId: v.string(), - profile: v.string(), - acpRuntime: v.string(), - eventType: v.string(), - payload: v.any(), - createdAt: v.number(), -}; - -function isoFromMillis(value: number) { - return new Date(value).toISOString(); -} - -async function requireScopedUser(ctx: any, expectedUserId: string) { - const identity = await ctx.auth.getUserIdentity(); - const subject = String(identity?.subject ?? ""); - const identityUserId = subject.split("|")[0] || ""; - if (identityUserId && identityUserId !== expectedUserId) { - throw new Error("无权限"); - } - if (!identityUserId && !expectedUserId.trim()) { - throw new Error("未登录"); - } - return expectedUserId; -} - -function rowToClient(row: any) { - return { - id: row.id, - userId: row.user_id, - workspaceId: row.workspace_id, - documentId: row.document_id, - sessionId: row.session_id, - runId: row.run_id, - profile: row.profile, - title: row.title, - acpRuntime: row.acp_runtime, - source: row.source, - status: row.status, - traceId: row.trace_id, - runtime: row.runtime, - usage: row.usage ?? null, - payload: row.payload, - retention: row.retention, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -export const upsertRuntimeRun = mutation({ - args: runtimeRunArgs, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const workspaceId = args.workspaceId ?? null; - const nowIso = isoFromMillis(args.updatedAt); - const existing = await ctx.db - .query("acp_runtime_runs") - .withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId)) - .first(); - - const patch = { - id: `acp_run:${args.runId}`, - user_id: userId, - workspace_id: workspaceId, - document_id: args.documentId, - session_id: args.sessionId, - run_id: args.runId, - title: args.title ?? null, - profile: args.profile, - acp_runtime: args.acpRuntime, - source: args.source, - status: args.status, - trace_id: args.traceId, - runtime: args.runtime, - usage: null, - payload: args.payload, - retention: args.retention, - updated_at: nowIso, - deleted_at: null, - }; - - if (existing) { - if (String(existing.user_id) !== userId) { - throw new Error("无权限"); - } - await ctx.db.patch(existing._id, patch); - return { ok: true, id: existing.id, runId: args.runId, updated: true }; - } - - await ctx.db.insert("acp_runtime_runs", { - ...patch, - created_at: isoFromMillis(args.createdAt), - }); - return { ok: true, id: patch.id, runId: args.runId, created: true }; - }, -}); - -export const getRuntimeRun = query({ - args: { - userId: v.string(), - runId: v.string(), - }, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const row = await ctx.db - .query("acp_runtime_runs") - .withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId)) - .first(); - if (!row || row.deleted_at !== null || String(row.user_id) !== userId) { - return null; - } - return rowToClient(row); - }, -}); - -export const listRuntimeRuns = query({ - args: { - userId: v.string(), - workspaceId: v.optional(v.union(v.string(), v.null())), - documentId: v.optional(v.union(v.string(), v.null())), - sessionId: v.optional(v.union(v.string(), v.null())), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const workspaceId = args.workspaceId ?? null; - const limit = Math.min(Math.max(args.limit ?? 50, 1), 100); - let rows; - if (args.sessionId) { - rows = await ctx.db - .query("acp_runtime_runs") - .withIndex("by_user_workspace_session", (q: any) => - q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", args.sessionId), - ) - .order("desc") - .take(limit); - } else if (args.documentId) { - rows = await ctx.db - .query("acp_runtime_runs") - .withIndex("by_user_workspace_document", (q: any) => - q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("document_id", args.documentId), - ) - .order("desc") - .take(limit); - } else { - rows = await ctx.db - .query("acp_runtime_runs") - .withIndex("by_user_created_at", (q: any) => q.eq("user_id", userId)) - .order("desc") - .take(limit); - } - - return rows - .filter((row: any) => row.deleted_at === null) - .map(rowToClient); - }, -}); - -export const appendRuntimeEvent = mutation({ - args: runtimeEventArgs, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const workspaceId = args.workspaceId ?? null; - const existingRun = await ctx.db - .query("acp_runtime_runs") - .withIndex("by_run_id", (q: any) => q.eq("run_id", args.runId)) - .first(); - if (existingRun && String(existingRun.user_id) !== userId) { - throw new Error("无权限"); - } - - await ctx.db.insert("acp_runtime_events", { - id: args.eventId, - user_id: userId, - workspace_id: workspaceId, - document_id: args.documentId, - session_id: args.sessionId, - run_id: args.runId, - profile: args.profile, - acp_runtime: args.acpRuntime, - source: args.source, - event_type: args.eventType, - payload: args.payload, - created_at: isoFromMillis(args.createdAt), - }); - const usage = usageFromRuntimeEvent(args.eventType, args.payload); - if (existingRun && usage) { - await ctx.db.patch(existingRun._id, { - usage, - updated_at: isoFromMillis(args.createdAt), - }); - } - return { ok: true, id: args.eventId, runId: args.runId }; - }, -}); - -function usageFromRuntimeEvent(eventType: string, payload: any) { - if (eventType === "usage.updated") { - return { - source: "usage_update", - contextUsed: Number(payload?.used ?? payload?.contextUsed ?? 0), - contextSize: Number(payload?.size ?? payload?.contextSize ?? 0), - }; - } - if (eventType === "run.completed" && payload?.usage && typeof payload.usage === "object") { - return { - source: "run_completed", - ...payload.usage, - }; - } - return null; -} - -export const listRuntimeEvents = query({ - args: { - userId: v.string(), - runId: v.string(), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const limit = Math.min(Math.max(args.limit ?? 100, 1), 500); - const rows = await ctx.db - .query("acp_runtime_events") - .withIndex("by_run_created_at", (q: any) => q.eq("run_id", args.runId)) - .order("asc") - .take(limit); - return rows - .filter((row: any) => String(row.user_id) === userId) - .map((row: any) => ({ - id: row.id, - userId: row.user_id, - workspaceId: row.workspace_id, - documentId: row.document_id, - sessionId: row.session_id, - runId: row.run_id, - profile: row.profile, - acpRuntime: row.acp_runtime, - source: row.source, - eventType: row.event_type, - payload: row.payload, - createdAt: row.created_at, - })); - }, -}); - -function titleFromRun(row: any) { - const payload = row?.payload ?? {}; - const raw = String(payload.message ?? payload.input ?? payload.title ?? "").trim(); - if (!raw) return "当前页问答"; - return raw.length > 32 ? `${raw.slice(0, 32)}...` : raw; -} - -async function sessionRows(ctx: any, userId: string, workspaceId: string | null, sessionId: string) { - return await ctx.db - .query("acp_runtime_runs") - .withIndex("by_user_workspace_session", (q: any) => - q.eq("user_id", userId).eq("workspace_id", workspaceId).eq("session_id", sessionId), - ) - .collect(); -} - -export const renameRuntimeSession = mutation({ - args: { - userId: v.string(), - workspaceId: v.optional(v.union(v.string(), v.null())), - sessionId: v.string(), - title: v.string(), - }, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const workspaceId = args.workspaceId ?? null; - const title = args.title.trim(); - if (!title) throw new Error("标题不能为空"); - const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId); - for (const row of rows) { - if (row.deleted_at === null) { - await ctx.db.patch(row._id, { - title, - updated_at: new Date().toISOString(), - }); - } - } - return { ok: true, sessionId: args.sessionId, title, updated: rows.length }; - }, -}); - -export const autoTitleRuntimeSession = mutation({ - args: { - userId: v.string(), - workspaceId: v.optional(v.union(v.string(), v.null())), - sessionId: v.string(), - }, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const workspaceId = args.workspaceId ?? null; - const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId); - const title = titleFromRun(rows.find((row: any) => row.deleted_at === null)); - for (const row of rows) { - if (row.deleted_at === null) { - await ctx.db.patch(row._id, { - title, - updated_at: new Date().toISOString(), - }); - } - } - return { ok: true, sessionId: args.sessionId, title, updated: rows.length }; - }, -}); - -export const deleteRuntimeSession = mutation({ - args: { - userId: v.string(), - workspaceId: v.optional(v.union(v.string(), v.null())), - sessionId: v.string(), - }, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const workspaceId = args.workspaceId ?? null; - const rows = await sessionRows(ctx, userId, workspaceId, args.sessionId); - const deletedAt = new Date().toISOString(); - for (const row of rows) { - if (row.deleted_at === null) { - await ctx.db.patch(row._id, { - deleted_at: deletedAt, - updated_at: deletedAt, - }); - } - } - return { ok: true, sessionId: args.sessionId, deleted: rows.length }; - }, -}); - -function searchText(row: any) { - const payload = row?.payload ?? {}; - return [ - row.title, - row.session_id, - row.run_id, - row.profile, - row.status, - payload.message, - payload.input, - payload.title, - ] - .map((value) => String(value ?? "")) - .join(" ") - .toLowerCase(); -} - -function snippetFor(row: any, q: string) { - const payload = row?.payload ?? {}; - const raw = String(row.title ?? payload.message ?? payload.input ?? row.session_id ?? "").trim(); - if (!raw) return ""; - const index = raw.toLowerCase().indexOf(q.toLowerCase()); - if (index < 0) return raw.length > 80 ? `${raw.slice(0, 80)}...` : raw; - const start = Math.max(0, index - 24); - const end = Math.min(raw.length, index + q.length + 56); - return `${start > 0 ? "..." : ""}${raw.slice(start, end)}${end < raw.length ? "..." : ""}`; -} - -export const searchRuntimeSessions = query({ - args: { - userId: v.string(), - workspaceId: v.optional(v.union(v.string(), v.null())), - q: v.string(), - limit: v.optional(v.number()), - }, - handler: async (ctx, args) => { - const userId = await requireScopedUser(ctx, args.userId); - const workspaceId = args.workspaceId ?? null; - const q = args.q.trim().toLowerCase(); - if (!q) return []; - const limit = Math.min(Math.max(args.limit ?? 20, 1), 50); - const rows = await ctx.db - .query("acp_runtime_runs") - .withIndex("by_user_created_at", (query: any) => query.eq("user_id", userId)) - .order("desc") - .take(500); - const seen = new Set(); - const results = []; - for (const row of rows) { - if (row.deleted_at !== null) continue; - if ((row.workspace_id ?? null) !== workspaceId) continue; - if (!searchText(row).includes(q)) continue; - if (seen.has(row.session_id)) continue; - seen.add(row.session_id); - results.push({ - sessionId: row.session_id, - runId: row.run_id, - workspaceId: row.workspace_id, - documentId: row.document_id, - title: row.title ?? titleFromRun(row), - profile: row.profile, - status: row.status, - snippet: snippetFor(row, args.q), - score: 1, - }); - if (results.length >= limit) break; - } - return results; - }, -}); diff --git a/recycle/20260522-convex-runtime-retirement/convex/auth.config.ts b/recycle/20260522-convex-runtime-retirement/convex/auth.config.ts deleted file mode 100644 index f4eb5646..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/auth.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default { - providers: [ - { - domain: process.env.CONVEX_SITE_URL, - applicationID: "convex", - }, - ], -}; diff --git a/recycle/20260522-convex-runtime-retirement/convex/auth.ts b/recycle/20260522-convex-runtime-retirement/convex/auth.ts deleted file mode 100644 index d50e479c..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/auth.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Password } from "@convex-dev/auth/providers/Password"; -import { convexAuth } from "@convex-dev/auth/server"; - -export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ - providers: [ - Password({ - validatePasswordRequirements: (password: string) => { - if (password.length < 8) { - throw new Error("密码至少需要 8 个字符"); - } - }, - profile(params) { - const email = typeof params.email === "string" ? params.email : String(params.email ?? ""); - const name = typeof params.name === "string" ? params.name.trim() : ""; - return { email, name }; - }, - }), - ], -}); diff --git a/recycle/20260522-convex-runtime-retirement/convex/schema.ts b/recycle/20260522-convex-runtime-retirement/convex/schema.ts deleted file mode 100644 index afcaab84..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/schema.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; -import { authTables } from "@convex-dev/auth/server"; - -export default defineSchema({ - ...authTables, - - acp_runtime_runs: defineTable({ - id: v.string(), - user_id: v.string(), - workspace_id: v.union(v.string(), v.null()), - document_id: v.string(), - session_id: v.string(), - run_id: v.string(), - title: v.union(v.string(), v.null()), - profile: v.string(), - acp_runtime: v.string(), - source: v.literal("acp"), - status: v.string(), - trace_id: v.string(), - runtime: v.any(), - usage: v.any(), - payload: v.any(), - retention: v.any(), - created_at: v.string(), - updated_at: v.string(), - deleted_at: v.union(v.string(), v.null()), - }) - .index("by_run_id", ["run_id"]) - .index("by_user_workspace_document", ["user_id", "workspace_id", "document_id", "created_at"]) - .index("by_user_workspace_session", ["user_id", "workspace_id", "session_id", "created_at"]) - .index("by_user_created_at", ["user_id", "created_at"]), - - acp_runtime_events: defineTable({ - id: v.string(), - user_id: v.string(), - workspace_id: v.union(v.string(), v.null()), - document_id: v.string(), - session_id: v.string(), - run_id: v.string(), - profile: v.string(), - acp_runtime: v.string(), - source: v.literal("acp"), - event_type: v.string(), - payload: v.any(), - created_at: v.string(), - }) - .index("by_run_created_at", ["run_id", "created_at"]) - .index("by_user_workspace_session_created_at", [ - "user_id", - "workspace_id", - "session_id", - "created_at", - ]), -}); diff --git a/recycle/20260522-convex-runtime-retirement/convex/users.ts b/recycle/20260522-convex-runtime-retirement/convex/users.ts deleted file mode 100644 index af733e53..00000000 --- a/recycle/20260522-convex-runtime-retirement/convex/users.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { query } from "./_generated/server"; -import { mutation } from "./_generated/server"; -import { v } from "convex/values"; -import { getAuthUserId } from "@convex-dev/auth/server"; - -function normalizeAccount(value: string) { - return value.trim().toLowerCase(); -} - -export const resolveLoginAccount = query({ - args: { - account: v.string(), - }, - handler: async (ctx, args) => { - const account = normalizeAccount(args.account); - if (!account) { - return null; - } - - const users = await ctx.db.query("users").collect(); - const user = users.find((row: any) => { - const email = normalizeAccount(String(row.email ?? "")); - const name = normalizeAccount(String(row.name ?? "")); - return email === account || name === account; - }); - - if (!user) { - return null; - } - - return { - userId: user._id, - email: String((user as any).email ?? ""), - name: String((user as any).name ?? ""), - }; - }, -}); - -function normalizeUsername(raw: string): string { - const username = String(raw ?? "").trim(); - if (!username) throw new Error("用户名不能为空"); - if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符"); - if (/\s/.test(username)) throw new Error("用户名不能包含空格"); - return username; -} - -export const currentUser = query({ - args: {}, - handler: async (ctx) => { - const userId = await getAuthUserId(ctx); - if (userId === null) { - return null; - } - return await ctx.db.get(userId); - }, -}); - -export const setMyUsername = mutation({ - args: { username: v.string() }, - handler: async (ctx, args) => { - const userId = await getAuthUserId(ctx); - if (userId === null) throw new Error("未登录"); - - const username = normalizeUsername(args.username); - const existing = await ctx.db - .query("users") - .filter((q) => q.eq(q.field("name"), username)) - .first(); - - if (existing && String(existing._id) !== String(userId)) { - throw new Error("用户名已被占用"); - } - - await ctx.db.patch(userId, { name: username }); - return { ok: true, username }; - }, -}); diff --git a/recycle/20260522-convex-runtime-retirement/infra/convex/README.md b/recycle/20260522-convex-runtime-retirement/infra/convex/README.md deleted file mode 100644 index 45ef8277..00000000 --- a/recycle/20260522-convex-runtime-retirement/infra/convex/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Convex 自托管(Windows 本机) - -本目录用于在本机通过 Docker Compose 启动 Convex backend + dashboard,并使用 backend 自身的持久化卷存储(包含数据库与文件存储)。 - -在 local-first 方案下,这套 Convex 自托管环境只定位为历史数据导出、显式 cloud source / compat 和可选 sync replica 运行底座;默认 auth、授权、分享、同步状态、AI policy、ACP/Hermes runtime session 等控制面已由 Rust SQLite control-plane 承接。页面正文、默认附件和本地 AI 会话全文不再以它作为早期产品默认数据真相。 - -## 当前 functions 边界 - -- 仓库根 `convex/` 已退役并软删除到 `recycle/20260522-convex-runtime-retirement/convex/`;它只作为审计、迁移对照或历史恢复素材,不再是 active deploy source。 -- `recycle/wolai-frontend/convex/` 是退役前端随带的历史实现,不允许作为当前部署 fallback。 -- `scripts/run-convex-deploy.js` 已随根 functions 源码软删除到 `recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js`。如需重新部署 Convex functions,必须先明确新的 cloud/compat 部署边界,不应直接恢复旧根 `convex/` 作为默认控制面。 - -## 启动 - -1. 确保仓库根目录 `.env.all` 存在(全局唯一 env 文件)。 -2. 在本目录执行: - -```bash -docker compose up -d -``` - -## 生成 Dashboard / CLI admin key - -在本目录执行: - -```bash -docker compose exec backend ./generate_admin_key.sh -``` - -## 访问地址(默认) - -- Dashboard:`http://localhost:6791` -- Backend:`http://127.0.0.1:3210` -- HTTP Actions:`http://127.0.0.1:3211` -- 文件存储:由 Convex 管理(Dashboard 的 Files 页面可查看) - -## 快速验证(可选) - -### 1) 验证默认启动不依赖 Convex functions 源码 - -当前默认 hot 启动链路不应要求仓库根存在 `convex/`: - -```bash -test ! -d convex -npm run desktop:hot -``` - -### 2) 验证固定开发用户(阶段 3) - -启动前端后访问:`http://127.0.0.1:3000/api/dev/whoami` - -### 3) 验证异步任务骨架(阶段 5) - -启动前端后: -- `POST http://127.0.0.1:3000/api/dev/jobs/demo`(body 可选:`{ "ms": 800 }`)创建 demo job -- `GET http://127.0.0.1:3000/api/dev/jobs/demo?id=` 查询状态(queued/running/succeeded/failed) diff --git a/recycle/20260522-convex-runtime-retirement/infra/convex/docker-compose.yml b/recycle/20260522-convex-runtime-retirement/infra/convex/docker-compose.yml deleted file mode 100644 index 7b9b0075..00000000 --- a/recycle/20260522-convex-runtime-retirement/infra/convex/docker-compose.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: mnote-convex - -services: - backend: - # 说明:官方自托管镜像。需要时可将 :latest 固定为特定版本。 - # local-first 下该卷只承载控制面、cloud source、compat 和 sync replica, - # 不作为本地默认页面正文、附件或 AI 会话全文真相。 - image: ghcr.io/get-convex/convex-backend@sha256:2143ad479a997802e74ac52f5f1ce3d6e75309b1965e36069d1c939166e210eb - stop_grace_period: 10s - stop_signal: SIGINT - ulimits: - nofile: - soft: 65535 - hard: 65535 - ports: - - "3210:3210" - - "3211:3211" - volumes: - - convex_data_v1:/convex/data - env_file: - - ../../.env.all - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3210/version"] - interval: 5s - start_period: 10s - - dashboard: - image: ghcr.io/get-convex/convex-dashboard@sha256:a1fa8df3d320d1691c74bb9df7bb2c536bdef91ff3f5307b717dba97eec793d1 - stop_grace_period: 10s - stop_signal: SIGINT - ports: - - "6791:6791" - depends_on: - backend: - condition: service_healthy - -volumes: - convex_data_v1: diff --git a/recycle/20260522-convex-runtime-retirement/root/storage-convex-bridge b/recycle/20260522-convex-runtime-retirement/root/storage-convex-bridge deleted file mode 100644 index e69de29b..00000000 diff --git a/recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js b/recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js deleted file mode 100644 index d170776d..00000000 --- a/recycle/20260522-convex-runtime-retirement/scripts/run-convex-deploy.js +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env node -const { spawn } = require("child_process"); -const fs = require("fs"); -const path = require("path"); - -process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp"; - -const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff"; -const repoRoot = "/mnt/Data1T/mnote"; -const rootConvexDir = path.join(repoRoot, "convex"); -const requiredRootFiles = ["schema.ts", "aiSessions.ts"]; - -for (const fileName of requiredRootFiles) { - const filePath = path.join(rootConvexDir, fileName); - if (!fs.existsSync(filePath)) { - console.error(`[convex-deploy] 缺少当前 active Convex 文件:${filePath}`); - console.error("[convex-deploy] 不允许从 recycle/wolai-frontend/convex 回退部署;需要的函数必须迁回 root convex/ 或明确退役。"); - process.exit(1); - } -} - -const args = [ - "deploy", - "--url", "http://127.0.0.1:3210", // Backend port, NOT HTTP actions (3211) - "--admin-key", adminKey, - "--typecheck", "disable", - "--codegen", "disable", -]; - -const child = spawn("npx", ["convex", ...args], { - cwd: repoRoot, - stdio: "inherit", - env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" }, -}); - -child.on("close", (code) => { - process.exit(code || 0); -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md b/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md deleted file mode 100644 index 959c5334..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md +++ /dev/null @@ -1,170 +0,0 @@ -> 状态补充(2026-07-03):本稿为 OpenHub 初步融合稿,OpenHub AI 面板 / FastAPI / Redis / opencode client 方向仍保留参考价值;其中 WeKnora 默认知识库底座口径已被当前 OpenHub / native agent + LightRAG + Turso/libSQL 主线覆盖。`7-68-openhub-weknora-mnote-deep-fusion-v1.md` 已标记 stale,不再作为当前默认 provider 依据。 - -# 7-67 OpenHub Page AI 深度融合设计 v1 - -状态:process -Owner:07-ai / mnote-web / control-plane -日期:2026-06-25 - -## 1. 背景 - -`7-65` 的 opencode 官方 WebUI iframe 路线验证了 opencode runtime、同源反代、session binding、文件打开/刷新等接缝,但 UI 深度融合受 iframe 与官方 WebUI 结构限制。用户目标已经调整为:尽量复用成熟社区项目 OpenHub 的前端、消息库、权限、知识库 UI 和 opencode 接入模式,把 MNote Page AI 做成类似 VSCode + Cline 的一体化侧边栏,而不是 MNote 自研一个简陋聊天框。 - -当前 Page AI 主路径已回正为 OpenHub / native agent + LightRAG + Turso/libSQL: - -```text -MNote Rust SSR / control-plane / local workspace - -> OpenHub AI 面板(只暴露 AI 能力,裁剪非 AI 入口) - -> OpenHub FastAPI + Redis + OpenHub SQLite session/skill/tool permission;MCP/tool 由 MNote/LightRAG/opencode 工具配置承接 - -> OpenHub opencode client / opencode serve runtime - -> LightRAG knowledge provider(默认知识库底座) -``` - -`7-65` 降级为 opencode runtime / 官方 iframe fallback;`7-66` 降级为 native UI fallback;本稿的 Rust adapter / 自建 message store 路线也冻结为参考,不再作为执行主线。 - -## 2. 硬边界 - -- 不恢复 Reasonix / ZCode / Hermes / Board / CodexMobile 为 Page AI 默认后端。 -- 不新增 Page AI Leptos island;Page AI 仍属于 `mnote-web` host runtime 与普通前端资源。 -- 不直接引入 OpenHub 登录、admin、FileManager、KnowledgeManager 等非 AI 入口;用户、权限、workspace 真相归 MNote control-plane。 -- 不把 OpenHub 自带 SQLite 文本知识库当作 MNote 长期知识库底座;当前默认长期知识库 provider 是 LightRAG。 -- 不使用 `--dangerously-skip-permissions` 作为默认路径。 -- 不把 opencode 原生 session 列表裸露给前端;所有 session 必须绑定 MNote 用户、rootUri、page path。 -- 不通过高频轮询刷新消息或 changed files;优先使用 opencode event stream、MNote watcher、programmatic refresh。 - -## 3. OpenHub 可复用资产 - -### 3.1 前端组件 - -第一阶段优先复用并裁剪: - -- `SmartQueryPage.jsx`:聊天 shell、消息流、状态管理、移动端布局参考。 -- `ChatInput.jsx`:输入框、附件、快捷操作、发送体验。 -- `AssistantMessage.jsx`:Markdown、代码块、tool result、reasoning 展示基础。 -- `ToolCall.jsx`:工具调用卡片与权限提示参考。 -- `HistoryDrawer.jsx`:历史会话抽屉。 -- `DiffViewer.jsx`:变更展示,但文件打开必须接 MNote open-resource。 -- `FileManager.jsx`:映射 MNote local workspace / file tree。 -- `KnowledgeManager.jsx`:知识库 UI 参考,后端改接 MNote/WeKnora adapter。 - -暂缓融合: - -- OpenHub Login/Admin 整页。 -- SmartEntity / Team / Scheduler。 -- GitTimeMachine 的 restore 写入能力;可先只显示 diff / changed files。 - -### 3.2 后端模式(已由 7-68 覆盖) - -可复用其 API contract 和 opencode 调用模式: - -- `/api/query/stream` -- `/api/sessions` -- `/api/sessions/{sessionId}/messages` -- `/api/knowledge/*` -- `/api/files/*` -- opencode `/session?directory=`、`/session/{id}/prompt_async?directory=`、`/global/event?directory=` - -本稿原判断是“实现落在 Rust `mnote-web` / control-plane,不新增常驻 FastAPI 后端”。`7-68` 已更正:第一阶段保留 OpenHub FastAPI + Redis + opencode client 作为 AI 运行栈,MNote 只做登录态、scope 注入、入口裁剪、文件打开和 citation 回跳桥。 - -## 4. MNote 目标架构 - -### 4.1 UI 层 - -`sidebar-page-ai-runtime.js` 不再维护自研聊天消息渲染主链,而是承载 `7-68` 的 OpenHub AI 面板嵌入: - -- MNote-native host chrome:当前页、selection、workspace、授权状态、runtime 状态、changed file chips。 -- OpenHub AI 面板:消息、tool call、diff、history、input、skill/permission、WeKnora CLI/MCP tool 状态、运行日志。 -- Bridge:只处理 MNote 专属动作:`open-file`、`refresh-file`、`insert-context`、`session-ready`、`changed-files`。 - -### 4.2 Rust adapter 层(冻结为旧设想) - -以下自建 session/message 表路线已被 `7-68` 覆盖,不作为当前执行主线: - -- `page_ai_sessions`:MNote 用户维度的跨浏览器 session binding。 -- `page_ai_messages`:用户消息、assistant 消息、tool call、opencode ids、状态。 -- `page_ai_context_snapshots`:当前页标题、真实 Markdown 路径、selection、rootUri、allowed roots、知识摘要。 -- `page_ai_changed_files`:opencode event/diff 得到的 changed files 与 MNote resource mapping。 - -当前主线是:OpenHub session/message 是 Page AI 会话真相;MNote control-plane 只保存 binding、scope、artifact/open-reference 索引,禁止复制消息全文主存储。 - -### 4.3 opencode runtime 层 - -短期:单 opencode server + 当前打开 rootUri + MNote 用户级 binding。 - -中期:按 MNote user/profile 隔离 XDG profile,按需启动、空闲回收。 - -多用户强隔离不由 OpenHub 原生保证,必须由 MNote 反代与 profile manager 实现。 - -### 4.4 知识库层 - -OpenHub 知识库结论:其自带实现是 `knowledge_bases + knowledge_sources + LIKE/BM25/TF-IDF + prompt stuffing`,不是完整 RAG。 - -MNote 采用: - -- UI:复用 OpenHub `KnowledgeManager` 交互。 -- API:提供 OpenHub-compatible `/api/knowledge/*`。 -- Provider:默认走 LightRAG。 -- Fallback:未配置 WeKnora 时,可临时用 OpenHub-like SQLite 文本知识源做短知识。 -- Citation:WeKnora 结果必须保留 source/resource/open-reference 映射,方便点击回 MNote 文件或资源页。 - -## 5. 实施阶段 - -### Phase A:源码裁剪 spike - -- [ ] 抽取 OpenHub Chat 组件依赖图,确认最小可运行组件集。 -- [ ] 在 `mnote-web` 静态资源中引入 OpenHub-derived bundle 或独立构建产物。 -- [ ] 去除 OpenHub 登录/admin 路由依赖,改用 MNote 当前登录态。 -- [ ] 用静态 fixture 跑出接近 OpenHub 原始体验的 Page AI sidebar。 - -### Phase B:OpenHub-compatible session/message API - -- [ ] 增加 MNote control-plane session/message 表。 -- [ ] 实现 `/api/page-ai/openhub/sessions` 与 `/messages` adapter。 -- [ ] 绑定 `mnote_user_id + rootUri + pageAbsolutePath + opencode_session_id`。 -- [ ] 支持跨浏览器恢复同一 MNote 用户的会话。 - -### Phase C:真实 opencode streaming - -- [ ] adapter 创建/恢复 opencode session,directory 固定为当前打开 rootUri。 -- [ ] `/query/stream` 转发到 opencode prompt_async + global event。 -- [ ] 保存 user/assistant/tool/diff 消息。 -- [ ] 解析 changed files 并驱动 MNote changed file chips。 - -### Phase D:MNote 文件与刷新融合 - -- [ ] changed file chip 点击走 `openResourceInActiveTab()`。 -- [ ] 当前页被修改后调用 `refreshPrimaryDocument()` 或 watcher 刷新链路。 -- [ ] DiffViewer 中所有 file path 点击都映射到 MNote resource/file open。 -- [ ] 文件路径必须限制在当前 rootUri / allowed roots 内。 - -### Phase E:Knowledge / LightRAG adapter - -- [ ] 兼容 OpenHub `knowledgeService` 的 list/create/upload/search/stats API。 -- [ ] 后端默认调用 LightRAG ingestion/search。 -- [ ] 将 LightRAG 命中结果转成 OpenHub UI 可展示的 source/citation。 -- [ ] 未配置 LightRAG 时启用 SQLite fallback,并在 UI 明确标注 fallback。 - -### Phase F:浏览器真实验证 - -- [ ] `npm run dev:hot` 一键拉起 MNote + opencode runtime + Page AI UI。 -- [ ] 登录测试账号后打开真实 Markdown 页面。 -- [ ] Page AI 看到 OpenHub-derived UI,而不是旧简陋聊天框或官方 iframe。 -- [ ] 发送真实消息,模型能识别当前 rootUri 内文件。 -- [ ] 让 opencode 修改测试 Markdown,MNote changed chip 可打开,当前页可刷新。 -- [ ] Knowledge UI 可上传/检索,LightRAG provider 有真实命中与引用。 -- [ ] 保存截图与 smoke 输出。 - -## 6. 验收标准 - -MVP 完成条件: - -- Page AI 主要视觉与交互来自 OpenHub Chat 子系统。 -- 会话和消息持久化在 MNote control-plane,支持同用户跨浏览器恢复。 -- opencode 真实流式回复可用,工作目录固定为当前打开 rootUri。 -- changed files 与 MNote open/refresh 打通。 -- Knowledge UI 至少能展示 WeKnora-backed 搜索结果;未接 WeKnora 时必须标注 fallback,不得声称知识库主线已完成。 -- `npm run dev:hot` 后可用真实浏览器截图证明。 - -## 7. 当前结论 - -OpenHub 仍是目前最适合 MNote Page AI 深度融合的参考实现,但本文已被 `7-68` 覆盖。正确路线不是把 OpenHub Page AI 子系统重写成 MNote 原生 UI,也不是由 MNote Rust adapter 重写 opencode client,而是在 MNote 侧嵌入 OpenHub AI 面板,保留 OpenHub FastAPI/Redis/opencode client,由 MNote 提供登录态、workspace scope、文件打开、citation 回跳和 WeKnora 工具/知识底座边界。 diff --git a/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md b/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md deleted file mode 100644 index f3926617..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md +++ /dev/null @@ -1,280 +0,0 @@ -# [stale] 7-68 OpenHub + WeKnora + MNote 深度融合执行 Checklist v1 - -状态:stale / reference-only(不再作为当前 process 主线) -Owner:07-ai / mnote-web / control-plane / knowledge-provider -日期:2026-06-25 - -2026-07-03 stale 说明:当前 runtime 口径已改为 OpenHub / native agent + LightRAG + Turso/libSQL。本文 checklist 中将 WeKnora 定义为默认知识库 provider、LightRAG 退为 legacy 的条目不再作为当前主线执行;当前知识库主路径以 LightRAG provider 和 MNote provider-neutral `mnote.knowledge_rag.*` facade 为准。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界。 - -上位设计:`design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md` - -## 0. 执行原则 - -- 保留各系统已完成能力:OpenHub 保留 AI 面板、FastAPI、SQLite session/message、Redis 临时态、skill/tool permission、opencode client;WeKnora 保留 KB/API/CLI/MCP/search/index;MNote 保留 auth/workspace/resource tree/document pane/source registry。 -- 只在产品真相冲突处做胶水:登录入口、workspace/rootUri scope、知识库 source truth、文件打开、citation 回跳、OpenHub 非 AI 入口裁剪、Git snapshot 禁用。 -- 不把未确认能力写成已完成能力:当前 OpenHub 源码未确认独立 MCP 管理 UI/API,第一阶段通过 MNote 注册 WeKnora CLI/MCP/API tool 到 OpenHub/opencode。 -- 不把 Redis 当持久真相:OpenHub message 真相在 SQLite,MNote auth/source/file truth 在 control-plane/本地文件,WeKnora Redis 属于其内部队列/缓存。 - -## 1. P0 设计冻结与旧路径降级 - -- [x] 在 `design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md` 顶部加覆盖说明:官方 opencode WebUI iframe 仅保留 fallback,不是 Page AI 主线。 -- [x] 在 `design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md` 顶部加覆盖说明:自研 native UI 仅保留 fallback,不继续扩自研聊天框。 -- [x] 在 `design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md` 顶部加覆盖说明:已被 7-68 覆盖,路线改为嵌入 OpenHub AI 面板并保留 FastAPI/opencode client。 -- [x] 搜索 `design/07-ai/`、`scripts/`、`rust/crates/mnote-web/browser/` 中仍把 LightRAG 描述为默认知识库 provider 的文案,列出需改文件,不在本任务中顺手改代码。 -- [x] 标记 `7-50` 到 `7-57` 中 LightRAG 相关 done 文档为历史事实:曾经完成,不再代表当前新主线。 - -P0 搜索记录(2026-06-25): - -- 已在 `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md`、`7-51`、`7-52`、`7-55`、`7-56`、`7-57` 顶部标记历史口径:LightRAG 曾完成,但不再代表当前新主线;WeKnora 是 7-68 默认 provider。 -- 仍需后续单独迁移或归档的 active/process 文档:`design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md`、`design/07-ai/process/7-54-raganything-multimodal-retrieval-alignment-v1.md`、`design/07-ai/process/7-61-yuxi-reference-middleware-extensions-dashboard-v1.md`。这些文档仍含 LightRAG 默认主线语义,应按 7-68 后续批次改为 WeKnora / provider-neutral 或移入 `design/old/`。 -- 仍需后续代码迁移的 LightRAG 残留:`rust/crates/mnote-web/browser/document-resource-tab-runtime.js`、`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`、`scripts/reasonix-acp-wrapper.mjs`、`scripts/task538-knowledge-rag-source-scope-api-smoke.js`、`scripts/task542-knowledge-rag-search-grouping-and-short-query-smoke.js`、`scripts/TESTING_REFERENCE.md`、`scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`。本轮先完成 7-68 主链静态边界与 WeKnora facade,不批量重写所有 legacy smoke。 - -## 2. P0 OpenHub 源码事实冻结 - -- [x] 固定 OpenHub 源码路径:`/tmp/mnote-openhub-research/OpenHub`;若后续要深改,先建立可复核源码快照或 CodeGraph 索引。 -- [x] 记录 FastAPI 入口:`smart-query-backend/app/main.py`,router 包括 `auth/query/session/admin/files/internal/knowledge/admin_knowledge/channels`。 -- [x] 记录 opencode client:`app/services/opencode_client.py` 使用 `OPENCODE_BASE_URL`,并支持 `directory` 参数。 -- [x] 记录 opencode launcher:`app/services/opencode_launcher.py` 可启动 `opencode serve`;集成时 `workdir` 必须来自 MNote rootUri,不用 OpenHub 默认路径。 -- [x] 记录 session/message 真相:`init_db.py` 的 `conversation_sessions/conversation_messages` 与 `app/database.py`。 -- [x] 记录 Redis 用途:`app/core/auth.py`、`app/database.py` 中的 token/rate-limit/workspace cache/图片临时态;不得写成消息队列主链。 -- [x] 记录自动 Git snapshot 触发点:`app/services/stream.py`、`app/services/task_executor.py`、`app/api/session.py` restore API。 -- [x] 记录冲突入口:`App.jsx` 的 `/login`、`/admin`;`FileManager.jsx`;`KnowledgeManager.jsx`;`GitTimeMachine.jsx`。 - -P0 OpenHub 记录(2026-06-25): - -- 源码路径已固定为 `/tmp/mnote-openhub-research/OpenHub`。 -- FastAPI 入口 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/main.py` include routers:`auth/query/session/admin/files/internal/smart_entity/smart_entity_tasks/knowledge/admin_knowledge/channels`。 -- `app/config.py` 中 `OPENCODE_BASE_URL` 默认 `http://127.0.0.1:4096`;`app/services/opencode_client.py` 的 `get/post/put/patch` 支持把 `directory` 写入 query params。 -- `app/services/opencode_launcher.py` 可启动 `opencode serve`;`main.py` 中 `opencode_auto_start` 当前默认 workdir 仍有 OpenHub 自带 fallback,MNote 集成时必须由 rootUri 派生或禁用 auto-start。 -- Git snapshot 写链真实存在:`app/services/stream.py`、`app/services/task_executor.py` 会 init git / has_changes / create_snapshot;`app/api/session.py` 暴露 snapshot list/detail/diff/restore/restore-file 路由。 -- 冲突入口真实存在:前端 `src/App.jsx` 有 `/login` 与 `/admin`;OpenHub 还包含 FileManager / KnowledgeManager / GitTimeMachine 相关入口,MNote host 第一阶段必须 guard 或转接。 - -## 3. P0 WeKnora 源码事实冻结 - -- [x] 固定 WeKnora 源码路径:`/mnt/Data1T/Mnote_data/weknora/WeKnora`。 -- [x] 记录 KB UI 入口:`frontend/src/views/knowledge/KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`frontend/src/components/knowledge-processing-timeline.vue`。 -- [x] 记录多 KB 检索:`docs/api/knowledge-search.md` 与 router `/api/v1/knowledge-search`,请求必须带 `knowledge_base_id(s)` 或 `knowledge_ids`。 -- [x] 记录单 KB hybrid-search:`client/knowledgebase.go` 的 `/api/v1/knowledge-bases/:id/hybrid-search`。 -- [x] 记录 `SearchResult` 全字段:`id/content/knowledge_id/knowledge_base_id/chunk_index/knowledge_title/start_at/end_at/seq/score/match_type/sub_chunk_id/metadata/chunk_type/parent_chunk_id/image_info/knowledge_filename/knowledge_source/knowledge_channel/chunk_metadata/matched_content/knowledge_description`。 -- [x] 记录 CLI MCP:`cli/internal/mcp/tools.go`、`cli/cmd/mcp/serve.go`;第一阶段默认采用只读 curated surface。 -- [x] 记录 Python `mcp-server/` 作为后续写能力参考,第一阶段不默认暴露给 OpenHub/opencode。 -- [x] 记录 WeKnora Redis/Asynq/Langfuse 属于 WeKnora stack,不和 OpenHub Redis keyspace 混用。 - -P0 WeKnora 记录(2026-06-25): - -- 源码路径已固定为 `/mnt/Data1T/Mnote_data/weknora/WeKnora`。 -- KB UI 入口已定位:`KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`knowledge-processing-timeline.vue`。 -- 多 KB 搜索合同来自 `docs/api/knowledge-search.md`:`POST /api/v1/knowledge-search`,请求至少包含 `knowledge_base_id` 或 `knowledge_base_ids`;可用 `knowledge_ids` 限定文件。 -- `client/knowledgebase.go` 记录 `HybridSearch` 路径 `/api/v1/knowledge-bases/:id/hybrid-search`;`SearchResult` 包含 `id/content/knowledge_id/chunk_index/knowledge_title/start_at/end_at/seq/score/match_type/chunk_type/image_info/metadata/knowledge_filename/knowledge_source/knowledge_channel/matched_content`,服务端还有 parent/sub/chunk metadata 等扩展字段,本轮 MNote mapper 以原始 chunk 保留这些字段。 -- CLI MCP 已定位:`cli/internal/mcp/tools.go` 注册 curated tools;`cli/cmd/mcp/serve.go` 构建 `weknora mcp serve`,当前 stdio-only。第一阶段 MNote 暴露自己的只读 facade,不默认开放 Python `mcp-server` 写能力。 - -## 4. P1 OpenHub 服务栈接入 - -- [x] 为 MNote dev-hot 增加 OpenHub 开关:`ENABLE_OPENHUB=1`、`OPENHUB_BACKEND_CMD`、`OPENHUB_BACKEND_PORT`、`OPENHUB_REDIS_URL/DB`、`OPENHUB_OPENCODE_BASE_URL`。 -- [~] 启动顺序明确为 Redis -> opencode serve -> OpenHub FastAPI -> MNote host health proxy。 -- [x] health 输出区分 `openhub_fastapi`、`openhub_redis`、`opencode_serve`、`weknora_api`、`weknora_cli_mcp`、`mnote_binding`。 -- [x] 禁止 kill-port/destructive launcher 行为;端口被占用时只报告占用 PID 和进程名。 -- [~] OpenHub `opencode_auto_start` 若启用,`opencode_workdir` 必须由 MNote 当前 rootUri 派生;否则 MNote 自己启动 opencode serve 并让 OpenHub 复用。 -- [x] 加环境开关或最小 fork patch 禁用 OpenHub 自动 `git init/commit/restore`。 -- [~] smoke:OpenHub FastAPI 可 `/openapi.json` 或 health 探针;Redis 可认证连接;opencode serve 可访问;WeKnora CLI profile/doctor 可通过;WeKnora stdio MCP handshake 仍未作为最终通过项。 - -P1 OpenHub 服务栈记录: - -- `scripts/desktop-hot.js` 已增加 `ENABLE_OPENHUB`、`OPENHUB_CMD`、`OPENHUB_PORT`、`OPENHUB_BACKEND_DIR`、`OPENHUB_REDIS_HEALTH_URL`、`MNOTE_OPENHUB_BASE_URL`、`MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE`,并补 `scripts/desktop-hot.test.js`。 -- 当前实现的变量名是 `OPENHUB_CMD` / `OPENHUB_PORT`,不是 checklist 早期草案里的 `OPENHUB_BACKEND_CMD` / `OPENHUB_BACKEND_PORT`;后续若需要兼容别名可再补。 -- `desktop-hot` 不释放或 kill OpenHub 端口;OpenHub Redis 目前只有 health URL 检查,不负责启动 Redis。 -- `MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=1` 已作为 MNote 启动环境默认值和 smoke 断言;OpenHub 研究源码 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend` 已最小 patch:`git_snapshot.py` 中央拦截 `init/config/add/commit/checkout/restore/reset/revert`,`stream.py`、`task_executor.py` 跳过自动 snapshot,`session.py` 的 restore / restore-file 在禁用时返回 403。`scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js` 和 Python guard 探针已通过。 -- 当前真实探针:OpenHub FastAPI `127.0.0.1:18080`、opencode `127.0.0.1:4096`、MNote `3021` 均可达;WeKnora 在 MNote 进程携带 `MNOTE_WEKNORA_API_KEY` 后 `/api/page-ai/openhub/status` 返回 ready。Redis 目前只作为 OpenHub 内部可选依赖/降级链路,MNote health 仅支持外部 URL 探针,不负责启动 Redis;已用 WeKnora `.env` 的 `REDIS_PASSWORD` 真实 `AUTH + PING` 返回 `+OK/+PONG`。 -- 2026-06-26 WeKnora CLI 真实 profile smoke:临时 `XDG_CONFIG_HOME` 下执行 `weknora profile add mnote-local --host http://127.0.0.1:8080 --use`,再把 MNote 进程中的 `MNOTE_WEKNORA_API_KEY` pipe 给 `weknora auth login --with-token`,`weknora doctor --no-cache --format json` 返回 4/4 checks passed(base URL、auth、server version、credential storage)。`weknora mcp serve --help` 通过;stdio MCP handshake 探针未稳定完成,保留为 dev-hot/orchestration 后续项。 - -## 5. P1 登录与 OpenHub Scope - -- [x] 新增/扩展 MNote OpenHub bootstrap endpoint:输入当前 `mnote_session + workspace/rootUri + page_resource_id`,输出 OpenHub runtime scope。 -- [x] 定义 `openhub_user_key = stable_hash(mnote_user_id)`。 -- [x] 定义 `openhub_workspace_key = stable_hash(workspace_id, root_uri)`。 -- [x] 定义 `openhub_session_scope = hash(mnote_user_id, workspace_id, root_uri, page_resource_id)`。 -- [x] 定义 `weknora_tool_scope = allowed_kb_ids + allowed_source_ids + citation_policy`。 -- [x] 禁用 OpenHub 前端 `/login` 跳转和 localStorage token 作为 MNote 产品入口;由 MNote proxy/header/bootstrap 注入受控身份。 -- [x] 保留 OpenHub 后端 user/session owner 校验;映射到 MNote 派生 OpenHub user,不共享单一 OpenHub 用户。 -- [~] rootUri/workspace 切换时新建或切换 OpenHub session;旧 binding 标记 `stale/archived`。 -- [x] 测试:两个 MNote 用户同一页面不能读到同一个 OpenHub session/message/skill/tool permission scope。 - -P1 scope 记录: - -- 新增 `rust/crates/mnote-web/src/routes/page_ai_openhub.rs`,`/api/page-ai/openhub/bootstrap` 输出 camelCase 与 snake_case scope:`openhubUserKey/openhub_user_key`、`openhubWorkspaceKey/workspace_key`、`openhubSessionScope/session_scope`、`skillScope/skill_scope`、`toolPermissionScope/tool_permission_scope`、`weknoraToolScope/weknora_tool_scope`。 -- MNote 侧已拒绝 OpenHub JWT/localStorage 作为产品入口;`/page-ai/openhub/login` 等非 AI 路由由 MNote guard。 -- OpenHub 研究源码已消费 `X-MNote-*` headers 派生后端 user/scope,并保留 session owner guard。 -- 2026-06-26 `node --check scripts/task775-openhub-mnote-scope-isolation-smoke.js` 与 `node scripts/task775-openhub-mnote-scope-isolation-smoke.js`:通过。直连 OpenHub MNote header scope 创建 session `task775-openhub-scope-1782410072554-157c8c`,Owner A 可读取自己写入的 user message,Owner B 读取同一 session messages 返回 403 `无权访问该会话`;stream 阶段模型不可用不影响 owner guard 验收,因为消息持久化和隔离均已验证。 - -## 6. P1 Page AI 嵌入 OpenHub AI 面板 - -- [x] 在 `rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` 设计嵌入方式:iframe 或 reverse proxy 二选一。 -- [x] 只暴露 OpenHub AI 页面;`/login`、`/admin`、FileManager、KnowledgeManager、GitTimeMachine、Team/Scheduler/SmartEntity 非 MVP 入口隐藏、404 或转 MNote。 -- [~] 保留 AI 主界面、history/session 抽屉、skill/agent 设置、tool/model permission、tool card、diff、changed files、运行日志。 -- [x] 如果 OpenHub 无 MCP 管理 UI,只显示 WeKnora MCP/CLI tool 连接状态和 MNote scope,不新建完整 MCP 管理器。 -- [x] MNote context 注入字段:user display、workspace/rootUri、page path/title/resource id、selection 摘要、allowed roots、session/skill/tool/weknora scope。 -- [x] changed file chip 点击走 MNote document pane bridge。 -- [x] OpenHub KnowledgeManager 入口若出现,跳转 MNote WeKnora 知识库页;不得打开 OpenHub knowledge tables。 -- [x] browser smoke:登录 MNote 后打开 Page AI,不出现 OpenHub/WeKnora 登录页。 -- [x] browser/API smoke:OpenHub session/message 已持久化并可读;刷新后 UI history 恢复已完成真实浏览器验收。 - -P1 Page AI host 记录: - -- `sidebar-page-ai-runtime.js` 默认打开 OpenHub host drawer,iframe 指向 `/page-ai/openhub/ai?scope=...`;`localStorage mnote.page_ai.openhub_host=0` 可回退 `/page-ai/opencode`。 -- `page_ai_openhub.rs` 与 `routes/mod.rs` 已挂 `/page-ai/openhub/ai` 静态 shell 和 `/page-ai/openhub/ai/{*path}` proxy;`login/admin/file/files/knowledge/git` 路由已 guard。 -- OpenHub React 前端已做 MNote embed 最小补丁:`mnoteEmbed.js` 识别 `/page-ai/openhub/ai` 嵌入态,`BrowserRouter` 使用 `/page-ai/openhub/ai` basename,API 默认走 `/page-ai/openhub/ai/api`,请求自动携带 `scope/mnoteScope`,无 token 不再发送 `Bearer null`,401 不跳 OpenHub `/login`,`/login/admin/file/files/knowledge/git` 前缀导航回 AI 根页;嵌入态隐藏 OpenHub FileManager、KnowledgeManager、GitTimeMachine、SmartEntity/Team 入口和对应 drawer。 -- MNote `ai_shell` 已从 OpenHub FastAPI 根 `/` 拉取 SPA index.html 并重写 `/assets/...` 到 `/page-ai/openhub/ai/assets/...`;OpenHub React dist 已复制到 `smart-query-backend/static/`。 -- 2026-06-26 浏览器 smoke `node scripts/task773-page-ai-openhub-browser-smoke.js`:通过。MNote 3000 可达,测试账号真实登录成功(`authMode=sqliteSession`),Page AI drawer、OpenHub host chrome 与 `/page-ai/openhub/ai?scope=...&mnoteScope=...` iframe 可见,未出现 OpenHub/WeKnora 登录页;iframe 内 OpenHub React UI 可见,文本 marker 包含 `OpenHub 平台/开始对话/历史记录/技能管理`,`openhubReactConnected=true`、`staticBoundaryOnly=false`,冲突入口 `文件管理/知识库/时光机/智能体/协作任务/团队状态` 不再出现在嵌入态正文。截图:`tmp/7-68-runtime/page-ai-openhub-browser.png`;结果 JSON:`tmp/7-68-runtime/page-ai-openhub-browser-result.json`。 -- 2026-06-26 send/history smoke `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。MNote SQLite 测试账号登录后,经 MNote proxy 调 OpenHub `/api/models` 返回 66 个真实 opencode 模型,选用 `opencodego/deepseek-v4-flash` 发送真实 stream,OpenHub SQLite 可读取同一 session 的 user/assistant message。结果 JSON:`tmp/7-68-runtime/openhub-send-smoke-result.json`。 -- 2026-06-26 changed files bridge smoke `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过。OpenHub MNote host mode `/api/sessions/{session_id}/diff` 从 opencode tool event metadata 提取真实 changed file path,并返回 `diffAvailable=false` 与限制说明,不伪造 diff content;结果命中 `knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md`。OpenHub 前端 `DiffViewer` / `SmartQueryPage` 通过 `postMNoteOpenFile` 发送 `mnote:open-file`,MNote host 监听后复用现有 document pane open-file bridge。 -- 2026-06-26 `BASE_URL=http://127.0.0.1:3021 node scripts/task786-openhub-history-refresh-browser-smoke.js`:通过。真实浏览器创建 OpenHub session message,刷新后 history drawer 可见 marker,点击 session 后当前 message area 可见同一 marker;OpenHub API message/history 同步验证通过。该轮 stream 返回“所有模型均不可用” error event,但 user message/session/history 持久化与 UI 恢复已验收;真实 AI reply 由 `task774` 覆盖。截图:`tmp/task786-openhub-history-refresh-browser-smoke/openhub-history-refresh.png`。 - -## 7. P1 OpenHub 原生 opencode 链路 - -- [x] 保留 OpenHub FastAPI prompt/event/diff client,不在 MNote 重写 event parser。 -- [x] 确认 OpenHub prompt 请求携带 `directory = MNote rootUri`。 -- [x] 确认 OpenHub 能监听 opencode `/global/event` 并渲染 assistant/tool part。 -- [x] 确认 OpenHub 能读取 changed files/diff,并将 path 暴露给 MNote bridge。 -- [x] MNote 只存 artifact index:`openhub_session_id + kind + provider_id + path/citation payload`,不复制消息全文。 -- [x] 当前打开文档被 opencode/OpenHub changed-file bridge 标记后,MNote 走 watcher 或 `refreshPrimaryDocument()` 刷新。 -- [~] smoke:让 OpenHub AI 修改 rootUri 内测试 Markdown,MNote document pane 可见变更,且没有 OpenHub Git snapshot commit。当前已覆盖真实 opencode 文件修改 + 模拟 OpenHub changed-file bridge 刷新;单条端到端 AI 修改“当前已打开文档”仍可后续补强。 - -P1 opencode 链路记录: - -- MNote 当前只做 host/status/bootstrap/proxy 边界,没有重写 OpenHub prompt/event parser。 -- OpenHub 后端 `query.py` 已在 MNote host mode 下使用 `resolve_user_workspace(current_user)`,创建 session 与 stream prompt 均把 `directory` 指向 MNote rootUri 解析出的 workspace path。 -- OpenHub 后端 `query.py` 已补 MNote host mode `/api/models`:不再返回空模型,而是调用真实 opencode `/config/providers`,展开 provider/model 并按 opencode `default` 输出 `default/defaultBuild/defaultPlan`;opencode 不可达时返回明确 empty + error,不伪造 mock model。 -- 2026-06-26 主控复核:`curl -H X-MNote-* http://127.0.0.1:18080/api/models` 返回 `success=true`、`modelCount=66`、`source=opencode_config_providers`、默认 `openai/gpt-5.3-chat-latest`;`python -m py_compile` 覆盖 `query.py/auth.py/mnote_scope.py` 通过。 -- 2026-06-26 `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。经 MNote proxy 发送真实 OpenHub stream,事件包含 `session/step-start/text/reasoning/step-finish/message_complete/session_idle`,OpenHub SQLite `/api/sessions/{id}/messages` 可读 user/assistant 两条消息。 -- 2026-06-26 `MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过。opencode 在 MNote rootUri 内把 `knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md` 修改为 `status: MNOTE_OPENHUB_FILE_EDIT_OK`,stream 事件包含 `tool`,workspace `.git` 在前后均不存在,未创建 OpenHub Git snapshot。当前 document pane 刷新由后续 `task779` 覆盖。 -- 2026-06-26 `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过。MNote proxy fresh bootstrap 后访问 OpenHub `/api/sessions/ses_1002052f0ffeOpebUFbGq4EC76/diff` 返回 `source=opencode_tool_events`、`diffAvailable=false`,changed path 命中上面的真实 file-edit fixture;OpenHub Git snapshot 写链仍禁用,因此只暴露真实 changed path,不生成或伪造行级 diff。当前 document pane 刷新由后续 `task779` 覆盖。 -- 2026-06-26 `node scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js`:通过。浏览器打开 local-first 测试 Markdown 后,脚本在 rootUri 内模拟 OpenHub/opencode 改写磁盘文件,再触发 MNote OpenHub changed-file bridge;MNote 侧复用 `mnote:open-file` / local-folder event bus synthetic watch batch / `refreshPrimaryDocument()` / `openPrimaryDocument()`,primary document pane 原地显示新内容,并写入 `data-mnote-page-ai-openhub-document-pane-refresh` 诊断 marker。该 smoke 不新增应用轮询。 -- 2026-06-26 MNote 侧新增 `/api/page-ai/openhub/artifact-index` GET/POST 最小边界:写入 `schema=mnote.page_ai_openhub_artifact_index_record.v1` 的轻量索引,只接受 `openhub_session_id/openhubSessionId + kind + provider_id/providerId + path + citation_payload/citationPayload` 等 locator 字段;默认持久化到 rootUri 下 `.mnote/page-ai-openhub-artifact-index.json`,也可由 `MNOTE_OPENHUB_ARTIFACT_INDEX_PATH` 指定。该实现明确 `messageFulltextCopied=false`,并拒绝 `message/messages/content/text/transcript/conversationMessages/assistantMessage/userMessage` 等 OpenHub user/assistant message 全文字段;不是复制 OpenHub SQLite message 表。 -- 2026-06-26 `node --check scripts/task778-openhub-artifact-index-static-smoke.js` 与 `node scripts/task778-openhub-artifact-index-static-smoke.js`:通过。静态 smoke 断言 artifact index route、轻量字段、全文字段拒绝、最小文件持久化和本 checklist 记录。 -- 2026-06-26 主控 runtime 复核:登录 MNote 测试账号后 POST `/api/page-ai/openhub/artifact-index` 写入 `kind=changed_file`、`providerId=opencode-tool-event-1`、`path=knowledge-rag-fixtures-7-68/task774-openhub-file-edit-1782409309451.md`,GET 同 session 返回 1 条记录,`storage.messageFulltextCopied=false`;携带 `message` 全文字段的 POST 返回 `page_ai_openhub_artifact_index_forbidden_fulltext_field`。 -- 2026-06-26 OpenHub 嵌入态已接 MNote artifact index:`mnoteEmbed.js` 从 `mnoteScope` 解出 `rootUri/workspaceId/pageResourceId`,`SmartQueryPage.jsx` 在 changed files diff metadata 加载后 POST `kind=changed_file` 轻量记录,并在 WeKnora citation/tool output 出现时 POST `kind=citation` 轻量记录;请求字段限定为 `openhubSessionId/kind/providerId/path/citationPayload/rootUri/workspaceId/pageResourceId`,citationPayload 递归去除 `message/messages/content/text/transcript/quote/displayQuote/matched_content` 等全文或证据正文键,不发送 OpenHub message/content/text 全文字段。 -- 2026-06-26 `node --check scripts/task780-openhub-artifact-index-runtime-smoke.js` 与 `node scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过。smoke 验证 OpenHub 前端静态边界、runtime POST URL/request body 形状、`mnoteScope` 上下文字段、changed_file / citation 轻量 artifact index 记录,以及请求体不含全文字段。 - -## 8. P1 WeKnora 知识库页面替换 - -- [x] 建立 MNote `mnote_knowledge_bases` 模型:user/workspace/rootUri/name/provider/provider_kb_id/default_tool_enabled。 -- [x] 迁移/扩展 `mnote_knowledge_sources`:兼容旧 `lightRag*` 字段,新增 provider-neutral 字段。 -- [x] 知识库列表页复用 WeKnora KB list 体验:名称、描述、文档数、chunk 数、processing 状态、更新时间。 -- [x] 知识库详情页复用 WeKnora detail 体验:source 列表、状态、reparse/delete、搜索入口。 -- [x] source 添加入口使用 MNote FileTree/resource picker,不使用 WeKnora 独立文件真相。 -- [x] 入库前 MNote 校验 allowed roots;文件/文件夹/page resource 转成 WeKnora file/manual/url ingest。 -- [x] processing timeline 显示 WeKnora `pending/processing/completed/failed`,并同步到 registry 与 FileTree 灯号。 -- [x] 删除 source 只删除 provider index/registry 映射,不删除本地文件。 -- [x] browser smoke:创建 KB -> 添加本地文件夹 -> 看到 indexing -> 完成后可搜索。 - -P1 WeKnora 知识库模型记录: - -- 2026-06-26 新增 MNote 后端 `mnote_knowledge_bases` runtime registry:`schema=mnote.knowledge_bases.registry.v1`,持久化到 workspace root 下 `.mnote/index/mnote-knowledge-bases.json`,字段包含 `userId/workspaceId/rootUri/name/provider/providerKbId/defaultToolEnabled/sourceCount/chunkCount/status`;`status` 与 `ingest` 会在有 `rootUri` 时返回 `knowledgeBases`。 -- `mnote_knowledge_sources` 边界通过现有 `.mnote/index/lightrag-source-registry.json` 兼容落地:新增 provider-neutral 字段 `provider/providerStatus/providerSourceId/providerKnowledgeId/providerKnowledgeBaseId`,同时保留旧 `lightRagDocId/lightRagStatus/lightRagFilePath` 兼容字段;读写 registry 时会为旧记录补齐 provider-neutral 字段,不删除旧 registry。 -- WeKnora ingest 现在写入 source registry 的 provider-neutral 映射,并同步 `mnote_knowledge_bases.sourceCount`;search/open-reference 继续复用 provider ids 与旧 LightRAG/source registry 兼容映射,不破坏已完成 ingest/search/open-reference。 -- 2026-06-26 `node --check scripts/task781-knowledge-bases-registry-smoke.js`:通过。 -- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task781-knowledge-bases-registry-smoke.js`:通过。验证 status 可读 `mnote_knowledge_bases`、ingest 写 provider-neutral source 字段、search 复用同一 WeKnora KB/source 映射;结果文件 `tmp/task781-knowledge-bases-registry-smoke/result.json`。同一脚本曾因 WeKnora 异步索引窗口在 240s 内未返回新 source 而失败一次,随后重跑通过,说明 search readiness 仍有 provider 异步延迟风险。 -- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task785-weknora-create-kb-folder-ingest-search-smoke.js`:通过。真实 API 创建 WeKnora KB `df7bd813-fef2-40f4-b08d-b47e03a58cea`,ingest 本地文件夹下 2 个 Markdown source,scoped search 命中同一 KB/source 映射;结果文件 `tmp/task785-weknora-create-kb-folder-ingest-search-smoke/result.json`。 -- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task788-weknora-kb-folder-browser-flow-smoke.js`:通过。真实浏览器 UI 创建 WeKnora KB `dc5e35f6-4d0f-4289-ae79-de8f4dac8441`,UI 添加本地文件夹 source,source registry 映射 2 个 source,UI 指定新 KB 检索并展示 source result,API search 复核命中同一 KB/source;`uiCreatesKb=true`、`uiAddsFolder=true`、`browserVerifiesSearch=true`、`apiVerifiesSearch=true`。截图:`tmp/task788-weknora-kb-folder-browser-flow-smoke/weknora-kb-folder-browser-flow.png`。 -- 2026-06-26 修复 WeKnora KB 创建 payload:新增 `embedding_model_id`,默认 `builtin-mnote-embedding`,可由 `MNOTE_WEKNORA_EMBEDDING_MODEL_ID` / `WEKNORA_EMBEDDING_MODEL_ID` 覆盖;否则新 KB 会长期 processing 且 search 无结果。 -- 2026-06-26 修复 scoped search:`sourcePaths` 从 registry 推导 `knowledge_ids`,并在 WeKnora 返回后按 source scope 二次过滤,避免同 KB 下其他旧文件污染指定 source 检索。 - -## 9. P1 WeKnora 检索替换 LightRAG - -- [~] 在 `rust/crates/mnote-web/src/routes/knowledge_rag.rs` 或拆分模块中抽出 `KnowledgeProvider` boundary。 -- [x] 新增 `weknora` provider,旧 LightRAG 标为 `lightrag_legacy` 并默认隐藏。 -- [x] `/api/knowledge-rag/status` 改为 WeKnora health + KB/source registry 状态。 -- [x] `/api/knowledge-rag/ingest` 改为 WeKnora ingest,写 provider KB / knowledge / source hash。 -- [x] `/api/knowledge-rag/search` 调 `/api/v1/knowledge-search`;单 KB 调试可走 hybrid-search。 -- [x] `/api/knowledge-rag/query` 第一阶段用 search results + citations 生成 answer envelope,不启用 WeKnora chat session。 -- [x] `/api/knowledge-rag/section-context` 改为 WeKnora chunk/list/by-id + MNote locator,不读 LightRAG sidecar。 -- [~] `/api/knowledge-rag/open-reference` 仅接受 provider ids / open-reference token,回查 registry 后生成 MNote locator。 -- [x] 映射保留 `content/matched_content/match_type/metadata/chunk_metadata/image_info/parent_chunk_id/sub_chunk_id`。 -- [x] UI 不把 score 显示为百分比相似度;只显示排序或弱化分值。 -- [x] 单测:SearchResult -> MNote result/citation/open-reference;缺 registry 映射时降级且不伪造路径。 -- [x] smoke:WeKnora ingest 本地 md -> MNote search 返回 provider=`weknora` -> open-reference 打开 MNote 文档。 - -P1 WeKnora 检索记录: - -- `knowledge_rag.rs` 默认 provider 为 `weknora`,`MNOTE_KNOWLEDGE_PROVIDER=lightrag|lightrag_legacy` 才进入 legacy。 -- `/api/knowledge-rag/status` 返回 `providerConfig.active/default/legacyFallback/weknora/lightrag`、WeKnora health、legacyHealth、registry。 -- `/api/knowledge-rag/ingest` 默认 WeKnora provider 时通过 `POST /api/v1/knowledge-bases/:id/knowledge/file` multipart 上传本地授权文件,写回 `providerKnowledgeBaseId/providerKnowledgeId/sourceHash/providerStatus`;缺 KB id 时只登记 `pending_provider_ingest_missing_kb`,不碰 LightRAG staging。 -- `/api/knowledge-rag/search` 与 `/api/knowledge-rag/query` 调 WeKnora `/knowledge-search`,请求包含 `query + knowledge_base_ids`,按需 `knowledge_ids/match_count`;API key 只在后端注入。 -- WeKnora chunk mapper 保留 provider ids、raw chunk、content/matched_content、metadata/chunk_metadata/image_info/parent/sub chunks;未命中 registry 时 `filePath=null`、`locatorDegraded=true`,不把 `knowledge_filename` 伪装成本地路径。 -- `sidebar-page-settings-runtime.js` 已收口 WeKnora 默认 provider、LightRAG legacy fallback、source registry、processing/citation/open-reference 文案;score 只显示 provider 排序分,不再表达为相似度百分比;删除 source 明确不删除本地文件。 -- `node scripts/task772-weknora-ingest-search-open-reference-e2e.js` 已真实通过:在 allowed root 下创建 `knowledge-rag-fixtures-7-68/task772-weknora-e2e-1782405331456.md`,MNote `/api/knowledge-rag/ingest` 返回 provider=`weknora`、`mappingStatus=provider_mapped`、`providerKnowledgeBaseId=90cc060c-3a16-4146-9d2b-fef8eb2c2d90`、`providerKnowledgeId=78b2c703-6d6a-4fa6-a881-f5ff6cfb769d`;`/api/knowledge-rag/search` 返回 4 条 weknora reference 并保留 provider ids;`/api/knowledge-rag/open-reference` 返回 `filePath=null`、`sourceRootRelativePath` 为 registry 映射路径、`openAction.params.path` 为本地 source path,未把 provider filename 伪造成本地路径。 -- `/api/knowledge-rag/section-context` 已改为 WeKnora provider chunk 优先:有 `providerChunkId` 时调 `/chunks/by-id/:id`,否则按 `providerKnowledgeId` 调 `/chunks/:knowledge_id`;返回 `providerChunkFetch.attempted/ok/source/fetchedChunks`、`providerChunk` metadata 与 MNote locator,且 `sidecarRead=false`。为适配 WeKnora 异步 parse 状态,section-context 对 WeKnora provider 已允许 `pending/submitted/processing/processed/completed/provider_mapped` 的 registry 映射进入 provider chunk fetch;LightRAG legacy sidecar 仍要求 `processed`。 -- 2026-06-26 `node scripts/task777-weknora-section-context-smoke.js`:通过。基于 task772 真实 ingest 结果,by-id 命中 `providerChunkId=78f026a5-7bb1-4b82-92f7-ee4f560f5640`,list 命中同一 `providerKnowledgeId=ec461ca5-37f7-471c-b4f7-4ced7be09a01`,两路均返回 `providerChunkFetch.ok=true`、`sidecarRead=false`、文本含 task772 marker。 -- 2026-06-26 `cargo test -p mnote-web knowledge_rag --lib -- --test-threads=1`:59 passed;`node scripts/task544-weknora-provider-bridge-static-smoke.js`:通过。 - -## 10. P1 WeKnora MCP/CLI Tool Bridge - -- [x] 定义 MNote 对 OpenHub/opencode 暴露的工具名:`mnote.weknora.search`、`mnote.weknora.open_reference`、`mnote.weknora.list_sources`、`mnote.weknora.get_source_status`。 -- [x] 底层第一阶段优先用 Go CLI `weknora mcp serve` 或 MNote HTTP facade 包装同等只读能力。 -- [x] tool 调用前注入 KB/source allowlist;禁止直接传未授权 `knowledge_base_id`。 -- [x] tool result 返回 provider ids、quote/matched_content、chunk metadata、MNote open-reference token。 -- [x] 不把 WeKnora API key 暴露给浏览器或 OpenHub 前端 localStorage。 -- [x] Python `mcp-server` 的 create/delete/chunk mutation 不默认暴露;如未来开启写能力,必须经过 MNote 权限审批。 -- [~] smoke:OpenHub AI 通过 WeKnora tool 查询 KB,回答中出现可点击 citation,点击后由 MNote 打开本地页面。当前完成 OpenHub host mode 自动 WeKnora search/tool event + clickable citation bridge;仍不是 opencode 原生 MCP 自动选 tool 的完整闭环。 - -P1 WeKnora tool 记录: - -- Hermes tool manifest 与 dispatch 已新增 `mnote.weknora.search/open_reference/list_sources/get_source_status`。 -- Tool wrapper 要求 `rootUri` 以及 `scope/allowlist/allowedRoots/aiAccessScope/sourcePaths` 之一,否则返回 `mnote_weknora_scope_required`。 -- 当前通过 MNote HTTP facade 调用 WeKnora;没有把 WeKnora API key 暴露到浏览器/localStorage,也没有默认开放 Python mcp-server 写能力。 -- 2026-06-28 主控收口:MNote 新增中性 tool executor alias `/api/mnote/tools/{manifest,call,audit}`,OpenHub backend 已改为调用 `/api/mnote/tools/call`;旧 `/api/hermes/tools/mnote/*` 只保留 legacy alias。OpenHub host mode 只读 facade 通过 MNote 反代受控 headers 与 server-side `X-MNote-Session-Cookie` 调 MNote tool executor,支持 `mnote.weknora.search/open_reference/list_sources/get_source_status`;OpenHub 不接触 WeKnora API key,也不把 MNote cookie 暴露给前端 localStorage。`stream.py` 在 MNote host mode 下对知识库/资料库/WeKnora/citation/source 类问题先调用 MNote WeKnora search,把结构化 tool result 注入 OpenHub/opencode prompt,并向 OpenHub 前端推送 `mnote.weknora.search` tool event。 -- 2026-06-26 task782 可写 worker:OpenHub 前端 `AssistantMessage` 从 WeKnora tool output 的 `citations/uiCitations/references` 渲染 `data-mnote-openhub-citation` 可点击 citation,点击经 `postMNoteOpenReference` 发 `mnote:open-reference`;MNote Page AI host 已接受 `openhub-citation` 并复用 document pane open-file bridge 打开本地页面。新增 `scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js` 覆盖后端 facade、scope/cookie/API key 边界、stream auto tool bridge、citation button 与 postMessage runtime。主控复核:OpenHub `/openapi.json` 已暴露 `/api/mnote/tools/call`,`node scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js` 通过;边界仍标 partial,不宣称 opencode 原生 MCP 自动选 tool 完成。 - -## 11. P1 验收与证据 - -- [~] `npm run dev:hot` 或目标启动命令能启动/检查 MNote、OpenHub FastAPI、OpenHub Redis、opencode、WeKnora API、WeKnora CLI/MCP。OpenHub/MNote/opencode/WeKnora API、Redis auth、WeKnora CLI profile/doctor 与 MCP 命令面已探通;stdio MCP handshake 和单命令 dev-hot orchestration 仍作为后续收口。 -- [x] 用 `mnote.e2e@example.com` 登录 MNote;Page AI 不出现 OpenHub/WeKnora 登录。 -- [x] Page AI 可发送消息,OpenHub SQLite session/message/history 持久化,刷新恢复。 -- [x] AI 修改当前 rootUri 内 Markdown,MNote 当前页面刷新后可见。当前证据由 `task774` 真实 opencode 文件修改 + `task779` 浏览器模拟 OpenHub changed-file bridge 刷新组成。 -- [x] OpenHub Login/Admin/FileManager/KnowledgeManager/GitTimeMachine 非 AI 入口不接管 MNote。 -- [x] WeKnora 知识库页完成 KB 创建、source 添加、状态显示、检索、citation 回跳。 -- [x] 旧 LightRAG 默认入口不再出现在主 UI/smoke;legacy fallback 显式标识。 -- [x] 保存 smoke 输出、关键截图、失败分层日志。 - -P1 当前验证证据: - -- `cargo check -p mnote-web`:通过。 -- `cargo test -p mnote-web knowledge_rag --lib -- --test-threads=1`:59 passed。 -- `node scripts/task544-weknora-provider-bridge-static-smoke.js`:通过。 -- `node scripts/task768-page-ai-openhub-host-static-smoke.js`:通过。 -- `node scripts/task769-weknora-knowledge-settings-ui-static-smoke.js`:通过。 -- `node scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js`:通过。 -- `node --check scripts/task773-page-ai-openhub-browser-smoke.js`:通过。 -- 早期 `node scripts/task773-page-ai-openhub-browser-smoke.js` 只验证到静态 shell,已被后续 React bundle 接入结果覆盖;保留为阶段证据,不作为当前最终状态。 -- `npm run build`(`/tmp/mnote-openhub-research/OpenHub/smart-query-frontend`):通过;Vite 仍提示主 chunk 超过 500 kB,这是既有体积 warning。 -- OpenHub React dist 已复制到 `/tmp/mnote-openhub-research/OpenHub/smart-query-backend/static/`,MNote `/page-ai/openhub/ai` 可加载 OpenHub SPA index/assets。 -- `node scripts/task773-page-ai-openhub-browser-smoke.js`:通过;测试账号真实登录,Page AI OpenHub host drawer/iframe 可见,OpenHub React UI marker 出现,未出现 OpenHub/WeKnora 登录页;`openhubReactConnected=true`、`staticBoundaryOnly=false`,嵌入态未显示 FileManager/KnowledgeManager/GitTimeMachine 等冲突入口。 -- `curl -H X-MNote-* http://127.0.0.1:18080/api/models`:通过;MNote host mode 返回 66 个真实 opencode 模型,`source=opencode_config_providers`,未使用 mock。 -- `node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过;MNote proxy -> OpenHub `/api/models` -> `/api/query/stream` -> OpenHub SQLite `/api/sessions/{id}/messages` 全链路通过,选用 `opencodego/deepseek-v4-flash`。 -- `MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js`:通过;opencode 修改 MNote rootUri 内测试 Markdown,最终内容含 `status: MNOTE_OPENHUB_FILE_EDIT_OK`,`.git` 未被 OpenHub 创建。 -- `node --check scripts/task772-weknora-ingest-search-open-reference-e2e.js`:通过。 -- `node scripts/task772-weknora-ingest-search-open-reference-e2e.js`:通过;当前 shell 未直接暴露 WeKnora env,脚本先输出 `local_weknora_env_not_visible` warning,但继续以 MNote 3000 的 status 和真实 API 调用为准,最终 ingest/search/open-reference 全链路通过。最新主控重跑创建 `knowledge-rag-fixtures-7-68/task772-weknora-e2e-1782408870763.md`,映射到 WeKnora KB `90cc060c-3a16-4146-9d2b-fef8eb2c2d90` 与 knowledge `c6789541-7ad5-47a4-b621-5ef1e2762688`。 -- `python -m py_compile /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/query.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/session.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/core/auth.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/core/mnote_scope.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/git_snapshot.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/stream.py /tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/task_executor.py`:通过。 -- `MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=1 PYTHONPATH=/tmp/mnote-openhub-research/OpenHub/smart-query-backend python ...`:通过,确认底层 git 写命令 guard 生效。 -- `node --test scripts/desktop-hot.test.js`:6 passed。 -- `git diff --check`:通过。 -- `codegraph sync .`:完成,报告 synced 38 changed files。 -- `node scripts/task776-openhub-changed-files-bridge-smoke.js`:通过;MNote proxy -> OpenHub changed files endpoint 返回真实 opencode tool-event path,且 OpenHub 前端到 MNote document pane open-file bridge 静态边界通过。 -- `node scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js`:通过;打开测试 Markdown 后改写 rootUri 内文件并触发 OpenHub changed-file bridge,document pane 可见刷新到新正文,诊断显示 `eventBusSource=openhub_changed_file_bridge`。 -- `node --check scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过。 -- `node scripts/task780-openhub-artifact-index-runtime-smoke.js`:通过;验证 OpenHub embed changed_file / citation 轻量 artifact index POST 形状,且不含 message/content/text 等全文字段。 -- 外部服务真实探针:`/api/page-ai/openhub/status` 在配置 WeKnora API key 后返回 `status=ready`,OpenHub FastAPI `127.0.0.1:18080` ready,opencode `127.0.0.1:4096` ready,WeKnora public `/health` ready,OpenHub `/openapi.json` 暴露 `/api/mnote/tools/call`,Redis `AUTH + PING` 返回 `+OK/+PONG`,WeKnora CLI `profile add + auth login --with-token + doctor --no-cache` 通过,`go run . mcp serve --help` 通过。stdio MCP handshake 未作为最终通过项,因此 dev-hot/CLI/MCP 全链路仍 partial。 -- 2026-06-26 task783/task784 UI 增量:`sidebar-page-settings-runtime.js` 在 WeKnora 设置面板增加 KB summary(KB/source/provider docs/processing)、本地文件/文件夹 source picker 边界标记、source row 与 search result 的 `open-source-reference` 控件;`sidebar-tree-runtime.js` 接线该控件到 MNote document pane `openPrimaryDocument`,仍复用 `/api/knowledge-rag/status/ingest/search/delete-source`,未改后端 `knowledge_rag.rs`。`node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js`、`node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js`、`node --check scripts/task783-weknora-kb-settings-ui-static-smoke.js`、`node --check scripts/task784-weknora-kb-settings-browser-smoke.js`、`node scripts/task783-weknora-kb-settings-ui-static-smoke.js`、`node scripts/task769-weknora-knowledge-settings-ui-static-smoke.js` 均通过。真实 browser smoke 使用临时 3018 MNote 实例与已有 task772 WeKnora 入库结果:`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3018 node scripts/task784-weknora-kb-settings-browser-smoke.js` 通过,截图 `tmp/task784-weknora-kb-settings-browser-smoke/weknora-kb-settings.png`;验证 KB summary 包含 KB `90cc060c-3a16-4146-9d2b-fef8eb2c2d90`,source row/search result/open-reference/delete-preserve-local-file 控件可见。仍 partial:未创建新的 KB;未真实点击 open-reference 后断言文档 pane 跳转;未完成 checklist 第 8 节“创建 KB -> 添加本地文件夹 -> indexing -> 完成后搜索”的完整链路。 -- 2026-06-26 task788 UI 完整链路增量:`sidebar-page-settings-runtime.js` 新增 WeKnora KB select、创建 KB 控件,ingest/search 带当前选中 `providerKnowledgeBaseId`;`sidebar-tree-runtime.js` 接线 create-kb 与 KB select change。主控复核 `node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js`、`node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js`、`node --check scripts/task788-weknora-kb-folder-browser-flow-smoke.js` 通过;临时当前构建 MNote `3022` 上 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task788-weknora-kb-folder-browser-flow-smoke.js` 通过,UI 创建 KB `dc5e35f6-4d0f-4289-ae79-de8f4dac8441`、UI 添加文件夹 source、UI 指定新 KB 检索、API search 复核均通过。 -- 2026-06-26 用户纠正后知识库页面替换增量:原“资料库问答/资料源管理”小面板不符合 7-68,已改为 WeKnora 风格“知识库列表 + 知识库详情/文档管理”页面。`sidebar-page-settings-runtime.js` 补入 KB 面包屑、Documents/Wiki/Graph/Search 页签、source/tag 侧栏、文档搜索/筛选、添加文档/文件夹、列表/网格切换、processing/status 与 open-reference 控件;`main.css` 补齐对应布局;新增 `scripts/task790-weknora-kb-page-experience-static-smoke.js` 与 `scripts/task79x-weknora-kb-page-browser-smoke.js`。 -- 2026-06-26 纠正后复核:`node --check rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js`、`node --check scripts/task790-weknora-kb-page-experience-static-smoke.js`、`node --check scripts/task79x-weknora-kb-page-browser-smoke.js`、`node scripts/task783-weknora-kb-settings-ui-static-smoke.js`、`node scripts/task789-weknora-kb-page-ui-reference-static-smoke.js`、`node scripts/task790-weknora-kb-page-experience-static-smoke.js`、`MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3022 node scripts/task79x-weknora-kb-page-browser-smoke.js` 均通过。浏览器结果确认 `openedReplacementPanel=true`、`notOldSimpleSettingsPanel=true`、`hasKnowledgeBaseListCards=true`、`hasDetailStructure=true`、`hasDocumentTabs=true`、`hasSearchAndFilter=true`;截图 `tmp/task79x-weknora-kb-page-browser-smoke/weknora-kb-page-browser-smoke.png`。 -- 2026-06-26 Page AI 截图复核:`MNOTE_UI_BASE_URL=http://127.0.0.1:3022 node scripts/task773-page-ai-openhub-browser-smoke.js` 通过,截图 `tmp/7-68-runtime/page-ai-openhub-browser.png`;OpenHub React UI marker 可见,`openhubReactConnected=true`、`staticBoundaryOnly=false`,未出现 OpenHub/WeKnora 登录页或 FileManager/KnowledgeManager/GitTimeMachine 冲突入口。 -- 2026-06-26 `BASE_URL=http://127.0.0.1:3021 node scripts/task786-openhub-history-refresh-browser-smoke.js`:通过;OpenHub session/message/history 刷新恢复已由浏览器和 API 双重验证,结果文件 `tmp/task786-openhub-history-refresh-browser-smoke/result.json`。 -- 2026-06-26 `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3021 node scripts/task787-weknora-default-lightrag-legacy-smoke.js`:通过;静态扫描 33 个主 UI/smoke 文件,`violations=[]`,status API 的默认/active provider 为 WeKnora,LightRAG 仅作为显式 legacy fallback。 - -## 12. P2 后续优化 - -- [ ] 若需要开放 OpenHub Admin/tool/model 管理,先做 MNote role -> OpenHub admin scope 映射设计。 -- [ ] 若需要完整 MCP 管理 UI,再单独设计 OpenHub/MNote MCP 管理面,不混入首版嵌入。 -- [ ] 若需要 WeKnora chat/agent-chat,作为 OpenHub tool 内部 provider call,不变更 Page AI session 真相。 -- [ ] 若需要版本恢复,走 MNote DocumentBuffer/watcher/显式用户确认策略,不恢复 OpenHub GitTimeMachine 写链。 diff --git a/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md b/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md deleted file mode 100644 index 29047a77..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md +++ /dev/null @@ -1,809 +0,0 @@ -# [stale] 7-68 OpenHub + WeKnora + MNote Page AI 嵌入式集成设计 v1 - -状态:stale / reference-only(不再作为当前 process 主线) -Owner:07-ai / mnote-web / control-plane / knowledge-provider -日期:2026-06-25 - -2026-07-03 stale 说明:本文仍保留 Page AI / OpenHub / WeKnora 融合的历史设计上下文,但当前 runtime 口径已改为 OpenHub / native agent + LightRAG + Turso/libSQL。本文中将 WeKnora 定义为默认知识库 provider、LightRAG 退为 legacy 的内容不再作为当前主线执行;当前知识库主路径以 LightRAG provider 和 MNote provider-neutral `mnote.knowledge_rag.*` facade 为准。WeKnora 仅保留为历史设计、参考实现或备用 provider 边界。 - -执行说明:本文不再是当前主设计,仅作为 WeKnora 方向历史方案和 OpenHub 集成参考。当前实现与后续新增能力不得从本文推导出“WeKnora 是默认 provider”或“LightRAG 已退出主线”的口径。 - -## 0. 结论先行 - -MNote Page AI 不应再沿“官方 opencode WebUI iframe”或“自研简陋聊天框”继续堆功能。新的主路线是: - -```text -MNote 当前文档页 / workspace / auth / resource truth - ├─ Page AI UI:嵌入 OpenHub AI 界面(只暴露 AI 面板能力) - ├─ OpenHub backend:运行 OpenHub FastAPI + Redis + OpenHub SQLite session/skill/tool permission - ├─ Agent runtime:OpenHub FastAPI 调用 opencode serve / opencode provider - ├─ Knowledge provider:WeKnora,通过 MNote 注册给 OpenHub/opencode 的 MCP/CLI/skill 工具调用 - └─ MNote boundary:统一登录授权、workspace/rootUri scope、文件打开、citation 回跳 -``` - -核心取舍: - -总原则:**各部分尽量保持原有已完成能力,只有发生产品真相冲突时才做最小胶水/嫁接/裁剪**。OpenHub 保持 AI 面板、FastAPI、Redis、SQLite session/message、skill/tool permission、opencode client;WeKnora 保持知识库底座、MCP/CLI/API;MNote 保持 workspace/auth/resource tree/document pane/source registry。MNote 不重写 OpenHub/WeKnora 已有能力,只在登录态、workspace scope、文件打开、citation 回跳、禁用无关入口这些冲突点上做最小移植。 - -- **OpenHub 负责 Page AI 界面与多用户 AI 运行栈**:嵌入 OpenHub AI 面板,运行 FastAPI/Redis,使用 OpenHub 的用户隔离、session、skill、agent/tool permission、opencode 调用链;MCP 由 MNote/WeKnora/opencode 工具配置承接,不把“OpenHub 已有完整 MCP 管理面”当成已确认事实;FileManager/KnowledgeManager/Admin/Login 等非 AI 入口掐断或转接。 -- **WeKnora 负责知识库底座、知识库页面参考实现和 MCP/CLI/API 工具能力**:解析、chunk、混合检索、RAG 引用、Wiki/图谱、知识库管理 API;知识库页面优先复用 WeKnora 的 KB list/detail/upload/status 体验,但不让 WeKnora 接管 MNote 文件真相。 -- **MNote 负责宿主真相与冲突胶水**:登录、用户、workspace/rootUri、resource tree、页面打开、allowed roots、session binding、source registry、citation/open-reference;不重写 OpenHub/WeKnora 已有主功能。 -- **opencode 负责 agent 执行**:文件编辑、diff、工具审批、模型调用。 - -不得把 OpenHub 登录入口、OpenHub FileManager、OpenHub KnowledgeManager、WeKnora RBAC/前端变成 MNote 的用户/文件/知识库真相。OpenHub AI session/message/skill/tool permission/FastAPI/Redis 可以作为 Page AI 运行真相,但必须受 MNote 派生的用户与 workspace scope 隔离;WeKnora 保持知识库底座真相,MNote 只做展示、授权和回跳映射。 - -## 1. 背景与当前状态 - -### 1.1 已知事实 - -- OpenHub 是一个基于 opencode 的多用户 AI 平台参考实现,前端 React/AntD 组件较完整,包含 Chat、History、ToolCall、Diff、FileManager、KnowledgeManager 等。 -- OpenHub 多用户主要是应用层隔离:后端带 `directory=` 调 opencode,自己用 SQLite 管用户、session、message、权限、技能/工具权限,并可能对 workspace 执行 Git snapshot / restore。 -- OpenHub 自带知识库是轻量 `knowledge_bases + knowledge_sources + 本地全文 content + SQLite LIKE 候选 + Python BM25/TF-IDF rerank + prompt stuffing`,不是完整 RAG 底座;默认注入上下文约 1200 字符。 -- WeKnora 已部署在本机 `/mnt/Data1T/Mnote_data/weknora/WeKnora`,并提供知识库、知识文件、chunk、`/api/v1/knowledge-search`、knowledge-chat、agent-chat、tenant/RBAC、共享空间、CLI/MCP 等能力。 -- MNote 当前 active 代码仍大量使用 `knowledge_rag` / `LightRAG` 命名;用户已明确当前放弃 LightRAG,因此需要 provider-neutral 化并切到 WeKnora。 -- MNote 当前已有 `/api/page-ai/opencode/*` 反代/绑定雏形和 `ai_external_conversation_bindings`;在本路线下应转为 OpenHub host/proxy/session binding 与 artifact/open-reference index,而不是重写 OpenHub FastAPI 的 opencode client。 - -### 1.2 本设计覆盖范围 - -本设计替代 `7-65` 的官方 opencode iframe 产品主线,并收敛 `7-67` 的 OpenHub 初步融合设想。核心不是重写三套系统,而是保留各自已有能力,只对冲突点做最小胶水: - -1. 登录/用户/权限冲突。 -2. WeKnora 知识库底座、MNote 展示层、MNote 注册给 OpenHub/opencode 的 CLI/MCP/skill/tool 调用边界。 -3. 页面/文件打开、changed files、citation 回跳边界。 - -## 2. 三方职责边界 - -| 能力 | MNote | OpenHub | WeKnora | 取舍 | -|---|---|---|---|---| -| 登录/用户 | MNote SQLite control-plane、`mnote_session` | OpenHub JWT/localStorage 禁用或后端注入;使用 MNote 派生 user/workspace | WeKnora tenant/RBAC/API key | MNote 是唯一登录入口;OpenHub 运行态按 MNote 用户/工作区派生 | -| workspace/rootUri | MNote local workspace、rootUri 授权 | OpenHub user workspace path / session scope / skill scope / tool scope | WeKnora tenant/KB | rootUri 归 MNote;OpenHub 的 workspace directory 由 MNote 授权 rootUri 派生 | -| Page AI UI | MNote sidebar host / iframe/proxy shell | OpenHub AI 界面 | WeKnora 不进入 Page AI 对话壳 | 嵌入 OpenHub AI 面板;隐藏或掐断非 AI 页面入口 | -| session/message | MNote 只做 host binding / ownership index | OpenHub SQLite conversation/session/message tables;Redis 只作缓存/临时态 | WeKnora chat sessions 暂弃用 | OpenHub session/message 是 Page AI 真相;MNote 不复制消息全文 | -| agent runtime | MNote 启停/健康检查/反代边界 | OpenHub FastAPI + Redis + opencode client | WeKnora CLI/MCP/API,agent-chat 暂弃用 | 运行 OpenHub 后端;由 OpenHub 调 opencode;MNote 不重写 opencode client | -| 知识库 UI | MNote shell / FileTree 灯号 / citation 回跳 | OpenHub KnowledgeManager 掐断或跳转 MNote | 复用 WeKnora KB list/detail/upload/status 页面体验 | 用 MNote shell + WeKnora KB adapter 替换 MNote 简陋页,但本地文件和授权仍归 MNote | -| 检索/RAG | MNote source registry / citation 回跳 / scope 集合定义 | OpenHub AI 通过 MNote 注册的 CLI/MCP/skill/tool 调 WeKnora | WeKnora hybrid/vector/graph/chunk | 知识库是 MNote 授权文件/文件夹集合的索引视图;WeKnora 是唯一索引与查询 provider | -| 文件打开 | MNote document pane/resource tab/FileTree,相当于 FileManager | OpenHub FileManager 掐断或跳转 MNote | WeKnora 不负责页面打开 | OpenHub AI 回复中的 path/citation 点击回 MNote 打开页面 | -| 权限审批 | MNote allowed roots + workspace grants + scope 注入 | OpenHub tool/model/skill permissions;MCP 走 opencode/WeKnora 工具配置 | WeKnora RBAC | MNote 提供授权边界;OpenHub 按原生权限规范执行;WeKnora RBAC 作 provider 防线 | -| Git / snapshot / restore | MNote watcher、buffer、用户显式保存/版本策略 | OpenHub Git snapshot 默认关闭 | 无关 | 第一阶段禁用 Git snapshot/restore,避免污染 local-first workspace | -| Redis/cache | MNote 不存 Page AI 消息真相 | OpenHub Redis 主要用于 token/rate-limit/cache/临时态;消息真相在 SQLite | WeKnora Redis/Asynq/Langfuse 由 WeKnora stack 管理 | Redis 归各自服务栈;不作为 MNote 权限/文件/知识库真相 | - -## 3. 登录、OpenHub 隔离与 WeKnora 授权融合 - -### 3.1 核心更正 - -这里不能简单写成“只使用 MNote 单一用户认证,然后 OpenHub/WeKnora 都完全无用户”。更准确的模型是保留各自用户/权限机制中有价值的部分,并由 MNote 在边界上做最小嫁接: - -```text -MNote 登录态 / user_id / workspace grants - ├─ OpenHub 派生隔离上下文:openhub_user_key + workspace/runtime/session/skill/tool scope - └─ WeKnora 派生知识库上下文:已授权 workspace/rootUri -> provider tenant/profile/KB/source registry -``` - -- **MNote 负责入口认证与授权判断**:当前用户是谁、能访问哪些 workspace/rootUri、能读写哪些 source。 -- **OpenHub 需要 per-user / per-workspace 隔离**:session、message、skill、tool permission、opencode directory、changed files 都必须绑定 MNote 用户与 workspace;MCP/tool scope 由 MNote 注册的 WeKnora/opencode 工具配置体现;不能所有 MNote 用户共用一个 OpenHub runtime identity。 -- **WeKnora 是唯一知识库底座,并通过 MCP / CLI / API 暴露给 OpenHub/opencode**:它只接收 MNote 已授权 workspace 的文件/文件夹集合 ingest/search/query 或工具调用;知识库可见性本身依赖 MNote 的 source registry 和 allowed roots,WeKnora 用户认证可以保持简单。 -- **WeKnora RBAC/API key 是 provider 防线**:不承担 MNote 产品层用户隔离,不把 WeKnora tenant/user 反向暴露成 MNote 登录体系。 - -### 3.2 冲突 - -OpenHub 和 WeKnora 的用户模型对 MNote 的影响不同: - -- OpenHub 前端会使用 `auth_token` / JWT / localStorage,并在 401 后跳转到 `/login`。 -- OpenHub 后端还有会话、消息、skill、tool/model permission、workspace path 和 Git snapshot 等用户相关状态;MCP 工具面由 MNote/WeKnora/opencode 配置承接。 -- WeKnora 有 tenant RBAC、Owner/Admin/Contributor/Viewer、共享空间与 API Key,但 MNote 的知识库使用场景主要来自“用户已授权 workspace/rootUri”。 -- MNote 已有 SQLite control-plane auth、`mnote_session` cookie、测试账号与 local workspace 授权。 - -如果直接嵌入 OpenHub 或 WeKnora Web UI,会出现三套登录入口、三套用户 id、三套权限判断;但如果把 OpenHub 也降成“无用户共享 runtime”,又会让 session、skill、工具审批、WeKnora tool scope 和文件变更串用户。 - -### 3.3 决策 - -- MNote 是唯一**前端登录入口**和产品层授权入口。 -- OpenHub 不保留自己的 Login 页面、JWT/localStorage 登录跳转,但 MNote boundary 必须为每个 MNote 用户派生 OpenHub runtime identity。 -- OpenHub 派生 identity 至少包含:`mnote_user_id`、`workspace_id`、`root_uri`、`openhub_user_key`、`opencode_session_scope`、`skill_scope`、`tool_permission_scope`、`weknora_tool_scope`。 -- OpenHub session/message/skill/tool permission 不得跨 `mnote_user_id + workspace_id/root_uri` 共享;MCP/tool 配置不得绕过 MNote 注入的 KB/source allowlist。 -- WeKnora 使用 MNote 后端服务 API key 或受控 profile 调用;前端不直接持有 WeKnora API key。 -- WeKnora KB/source 由 MNote 的 workspace grants、source registry、allowed roots 决定;WeKnora tenant/RBAC 只作为 provider 内部防线。 - -### 3.4 映射建议 - -短期本机/local-first: - -```text -MNote user_id + workspace_id/rootUri - -> OpenHub runtime identity: mnote:{user_id}:{workspace_id}:{root_hash} - -> OpenHub scopes: - session_scope = user_id + workspace_id + rootUri + page_resource_id - skill_scope = user_id + workspace_id + rootUri - tool_permission_scope = user_id + workspace_id + rootUri - weknora_tool_scope = user_id + workspace_id + allowed_kb_ids/source_ids - -> WeKnora provider profile: mnote-local 服务 API key - -> WeKnora KB: mnote-{workspace_id}-{purpose} - -> MNote source registry 记录 provider KB / knowledge / chunk 映射 -``` - -中期多用户/局域网: - -```text -MNote user_id + workspace membership - -> OpenHub runtime identity 按 user/workspace 派生或映射到受控 OpenHub user - -> MNote credential vault 选择 WeKnora service profile/API key - -> WeKnora 默认按 workspace/profile/KB 隔离,不强制每个 MNote 用户对应 WeKnora 用户 - -> 所有 OpenHub 可见性仍由 MNote 登录态 + OpenHub scope 校验 - -> 所有 WeKnora 结果仍由 MNote source registry / allowed roots 二次过滤 -``` - -不要在第一阶段为每个 MNote 用户强行同步 WeKnora RBAC;这会放大生命周期与权限同步复杂度。相反,第一阶段应优先保证 OpenHub 派生上下文隔离,因为 Page AI 的 session、skill、tool permission、WeKnora tool scope 和文件变更都直接依赖 MNote 登录态。 - -## 4. OpenHub Session / Message 真相 - -### 4.1 核心更正 - -这里不应设计“三方 session 融合”,也不应新增一套 MNote `page_ai_messages` 作为 AI 面板消息真相。应保留 OpenHub 已做好的 session/message 能力,只做 MNote ownership binding: - -```text -MNote Page AI 面板 - -> MNote 自有 Page AI 映射 OpenHub session/conversation - -> OpenHub session / conversation 是唯一 AI 面板会话真相 - -> MNote control-plane 只保存绑定、索引和打开/权限映射 - -> WeKnora 不参与 Page AI 会话真相 -``` - -- **OpenHub session 是 Page AI session 真相**:消息、turn、tool call、skill/tool 状态、WeKnora tool call 状态、history、retry、visible/hidden 等语义以 OpenHub conversation/session 模型为准。 -- **MNote 不复制消息主存储**:MNote 只需要保存 `mnote_user/workspace/rootUri/page_resource_id -> openhub_session_id/opencode_session_id` 的绑定,以及 changed file / citation 回跳所需的轻量索引。 -- **WeKnora 不产生会话冲突**:当前 WeKnora 主要作为 MCP/CLI/知识库工具 provider;`knowledge-chat` / `agent-chat` 暂时弃用,不纳入 Page AI 主链,因此不设计 `knowledge_session_id`。 - -### 4.2 冲突 - -真正的冲突不是“三套消息历史融合”,而是: - -- OpenHub session/message 本来就是 AI 面板的产品模型,MNote 自建 `page_ai_messages` 会变成第二份聊天真相。 -- MNote 仍需要知道某个 OpenHub session 属于哪个 `mnote_user_id + workspace_id/rootUri + page_resource_id`,否则无法做跨浏览器恢复、权限过滤和打开文件回跳。 -- WeKnora 的 agent-chat / knowledge-chat 若混入主链,会引入第二套 provider chat session;当前应明确弃用。 - -### 4.3 决策 - -MNote control-plane 只新增或复用**绑定/索引层**,不新增消息全文主表。表名可复用现有 `ai_external_conversation_bindings` 并扩展 metadata,不要求一定新建下列物理表: - -```text -page_ai_openhub_bindings - id - mnote_user_id - workspace_id - root_uri - page_resource_id - page_absolute_path - openhub_user_key - openhub_session_id - opencode_session_id nullable - status = active | archived | stale - metadata_json - created_at / updated_at / archived_at - -page_ai_artifact_index - id - binding_id - openhub_session_id - kind = changed_file | diff | citation | tool_call_ref | attachment_ref - provider = openhub | opencode | weknora - provider_id - payload_json - mnote_resource_id nullable - mnote_open_reference_json nullable - created_at -``` - -`page_ai_artifact_index` 不是消息真相,只是为了 MNote sidebar/document pane 能打开 changed files、diff、citation、attachment。消息正文、历史列表、tool card 展示、retry/隐藏状态仍从 OpenHub session/conversation 读取。 - -### 4.4 OpenHub session scope - -OpenHub session 必须绑定 MNote 登录态派生的 scope: - -```text -openhub_session_scope = hash(mnote_user_id, workspace_id, root_uri, page_resource_id) -openhub_user_key = stable_hash(mnote_user_id) -openhub_workspace_key = stable_hash(workspace_id, root_uri) -``` - -- 同一用户同一页面可恢复最近 active OpenHub session。 -- 切换 rootUri 或 workspace 必须新开 OpenHub session;旧 session 标记 stale 或 archived。 -- 不同 MNote 用户不得共享同一个 OpenHub session、skill scope、tool permission scope 或 WeKnora tool scope。 -- MNote boundary 对 OpenHub session 的读写必须先校验 `mnote_session` 与 binding ownership。 - -### 4.5 WeKnora session policy - -第一阶段不使用 WeKnora `knowledge-chat` / `agent-chat` 作为 Page AI 会话层: - -- WeKnora 通过 MCP/CLI/API 暴露知识库能力给 OpenHub/opencode 工具链。 -- WeKnora 检索结果返回 chunk/reference,MNote 负责 source registry 映射和 citation 回跳。 -- 如果未来启用 WeKnora agent-chat,只能作为 OpenHub tool call 的内部 provider call,不能成为 Page AI 历史会话真相。 - -## 5. Knowledge 融合 - -### 5.1 OpenHub 知识库定位 - -OpenHub `KnowledgeManager` 第一阶段不复用;其后端知识库也不应成为主线: - -- 数据模型只有 base/source,没有持久 chunk/citation/embedding。 -- 检索是 SQLite `LIKE` 候选 + BM25/TF-IDF 重排。 -- 注入是 prompt stuffing,总上下文默认约 1200 字符。 - -适合:作为 OpenHub 源码理解和对照材料。 - -不适合:MNote 知识库主线、fallback 知识库、KnowledgeManager 页面复用、长期资料库、复杂 PDF/图片/OCR、可点击 citation、跨文档图谱、长期 RAG。 - -### 5.2 WeKnora 能力定位 - -WeKnora 应承担 MNote 知识库 provider: - -- 知识库类型:以 WeKnora `KnowledgeBase.Type` 和 FAQ 配置为准;Wiki/图谱属于 WeKnora Wiki mode / graph 能力,不能未经接口枚举直接当作 KB type 写死。 -- 导入:文件、URL、Markdown/手工知识、外部数据源。 -- 文档处理:chunk、OCR/VLM/ASR、图谱抽取、问题生成、reparse。 -- 检索:多 KB / 指定 knowledge 检索优先对接 `POST /api/v1/knowledge-search`;单 KB 调试或 CLI/MCP 可走 `POST /api/v1/knowledge-bases/:id/hybrid-search`;返回分数按排序分处理,不能按百分比或原始相似度解释。 -- 问答:`POST /api/v1/knowledge-chat/:session_id`、`POST /api/v1/agent-chat/:session_id` SSE 作为后续可选 provider 能力;第一阶段 Page AI 主链暂弃用,不产生主会话真相。 -- 权限:tenant RBAC / shared organization 作内部防线。 - -MNote 侧不要把 WeKnora RBAC 当唯一隔离边界:WeKnora RBAC 可能受配置开关影响,关闭时 guard 可能记录但放行;MNote 必须始终按 `mnote_session + workspace/rootUri + source registry + allowed roots` 二次过滤。 - -### 5.3 MNote Knowledge Adapter / Search Replacement - -保留 MNote 对外 canonical API: - -```text -/api/knowledge-rag/status -/api/knowledge-rag/ingest -/api/knowledge-rag/search -/api/knowledge-rag/query -/api/knowledge-rag/section-context -/api/knowledge-rag/open-reference -/api/knowledge-rag/delete-source -/api/knowledge-rag/prune-registry -``` - -但内部主链从 LightRAG 聚合检索切到 WeKnora。当前 `knowledge_rag.rs` 的 `/api/knowledge-rag/search`、`/api/knowledge-rag/query`、`/api/knowledge-rag/section-context` 仍围绕 LightRAG `/query/search`、`/query/data`、sidecar block、reference mapper、rank/dedupe 展开;替换时不能只改 endpoint,需要把 provider 调用、结果模型、locator 映射和排序语义一起替换。 - -目标 adapter: - -```rust -trait KnowledgeProvider { - fn status(root_uri, workspace_id) -> ProviderStatus; - fn ensure_kb(scope) -> ProviderKbRef; - fn ingest(source) -> ProviderKnowledgeRef; - fn search(query, scope, filters) -> Vec; - fn query(question, scope, filters) -> ProviderQueryResult; - fn section_context(provider_ref, query, scope) -> ProviderSectionContext; - fn open_reference(provider_ref) -> MnoteOpenReference; - fn delete_source(provider_ref) -> DeleteResult; -} -``` - -新增 `weknora` provider 实现;旧 LightRAG provider 标记 legacy,不再作为默认。第一阶段 `query` 可以由 WeKnora search results + citations 组成 answer envelope,不启用 WeKnora `knowledge-chat` / `agent-chat` 会话。 - -检索替换原则: - -- `/api/knowledge-rag/search`:调用 WeKnora `/api/v1/knowledge-search` 或 KB 级 hybrid-search,返回 MNote `search_results.v1` 兼容结构。 -- `/api/knowledge-rag/query`:不再调用 LightRAG `/query/data`;第一阶段用 WeKnora search result 生成带 citations/references 的 query result。 -- `/api/knowledge-rag/section-context`:不再读 LightRAG sidecar blocks;改为基于 WeKnora chunk / source registry / 本地文件 locator 构造上下文。 -- 排序与阈值:WeKnora score 是 provider 排序/融合分,不能沿用 LightRAG 相似度阈值、百分比相似度或旧 rank 解释。 -- 引用映射:WeKnora `knowledge_id` / `chunk_id` / `knowledge_base_id` 只用于回查 registry,不能直接作为 MNote 文件路径。 - -WeKnora search result 到 MNote registry 的最低字段映射: - -| WeKnora 字段 | MNote 派生字段 | 说明 | -|---|---|---| -| `id` | `providerChunkId` | WeKnora chunk id | -| `knowledge_id` | `providerKnowledgeId` | 回查 registry 的主键之一 | -| `knowledge_base_id` | `providerKnowledgeBaseId` | provider KB 映射;`knowledge-search` 与部分 CLI 输出字段覆盖不完全时从请求 scope/registry 补齐 | -| `content` / `matched_content` | `quote` / `matchedText` | citation 文本来源;FAQ/相似问命中优先保留 matched_content | -| `chunk_index` | `chunkIndex` | 可用于同一 knowledge 内排序/定位 | -| `start_at` / `end_at` | `providerOffsets` | 只能作为 provider 内偏移,不能直接当 Markdown 行号 | -| `knowledge_filename` | `providerDisplayName` | 只用于显示,不能当本地路径真相 | -| `knowledge_source` / `knowledge_channel` | `providerSourceMeta` | 用于辅助映射和审计 | -| `match_type` | `providerMatchType` | 区分 vector/keyword/hybrid/enrichment/direct 等命中渠道 | -| `parent_chunk_id` / `sub_chunk_id` | `providerChunkHierarchy` | 父子 chunk 与上下文扩展 | -| `metadata` / `chunk_metadata` / `image_info` | `providerMetadata` | 图片/VLM、FAQ、自定义元数据与定位诊断 | - -`sourcePath`、`lineStart`、`lineEnd`、`mnoteResourceId`、`openReference` 必须由 MNote registry / locator 派生;WeKnora 未返回时不能伪造。 - -### 5.4 知识库定义、页面选择与 WeKnora Tool Bridge - -这里不应设计 `OpenHub-compatible /knowledge/*`,也不应把 OpenHub `KnowledgeManager.jsx` 作为第一阶段知识库页。知识库页面建议复用 WeKnora 的 `KnowledgeBaseList.vue` / `KnowledgeBase.vue` / `KnowledgeBaseEditorModal.vue` / 上传与 processing timeline 组件体验,作为 MNote 知识库展示层的实现。当前边界是: - -```text -MNote Knowledge UI(复用 WeKnora KB list/detail/upload/status 体验) - -> MNote source set / source registry / allowed roots - -> WeKnora ingest / search / status / open-reference - -MNote Page AI / OpenHub-style session - -> opencode 按 skill/tool/MCP 规范自行决定 tool call;MNote 只提供受限工具配置和 open-reference bridge - -> 已注册的 `mnote.weknora.*` MCP/CLI/API tool - -> WeKnora search/query - -> tool result 回 OpenHub session -``` - -决策: - -- **知识库不是新的文件真相**:真相永远是 MNote 授权 rootUri 下的本地文件/文件夹/page resource;知识库是这些 source 的命名集合、索引状态和检索配置。 -- **唯一知识库底座是 WeKnora**:OpenHub 自带 knowledge tables、KnowledgeManager 页面和 prompt stuffing 知识库第一阶段全部不使用。 -- **知识库页面选择 WeKnora 体验,但不直接接管 MNote shell**:第一阶段采用 `MNote shell + WeKnora KB adapter`。优先抽取或复刻 WeKnora `KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`knowledge-processing-timeline.vue` 的交互和数据模型;不 iframe WeKnora 全量产品前端,不暴露 WeKnora 登录/租户切换/独立文件真相。 -- **OpenHub 不管理知识库页面**:OpenHub 只在对话过程中通过 MCP/CLI/API tool 调用 WeKnora,tool result 进入 OpenHub session。 -- **MNote tool facade 是注册与权限边界**:OpenHub/opencode 不能直接持有 WeKnora API key,也不能绕过 MNote allowed roots / source registry;是否调用工具、如何组织 tool call 由 OpenHub/opencode 自己判断。 -- **WeKnora MCP surface 第一阶段选择 Go CLI `weknora mcp serve` 或 MNote facade 包装后的等价只读工具面**:该 surface 当前是手工维护的只读工具集,更适合先挂给 OpenHub/opencode。Python `mcp-server` 暴露 create/delete/chunk mutation 等更宽能力,第一阶段只作为参考,不默认接入。 - -第一阶段需要的接口不是 OpenHub-compatible knowledge API,而是三类接口: - -```text -MNote UI canonical API: - /api/knowledge-rag/status - /api/knowledge-rag/ingest - /api/knowledge-rag/search - /api/knowledge-rag/open-reference - /api/knowledge-rag/delete-source - /api/knowledge-rag/prune-registry - -WeKnora-page-in-MNote adapter: - list_kbs / create_kb / update_kb / delete_kb - list_sources / add_source_set / upload_or_link_source / reparse_source / delete_source - get_processing_status / get_citation_open_reference - -OpenHub/opencode tool facade: - mnote.weknora.search - mnote.weknora.open_reference - mnote.weknora.list_sources - mnote.weknora.get_source_status -``` - -WeKnora 页面 adapter 可以复用 WeKnora 前端组件/交互,但数据入口必须先经过 MNote source registry 与 allowed roots;`mnote.weknora.*` 可以底层走 WeKnora MCP、CLI 或 HTTP API,但对 OpenHub 暴露的合同必须是 MNote 权限过滤后的 tool contract。 - -推荐页面复用方式: - -1. **首选:MNote adapter 页面复刻 WeKnora 知识库体验**。在 MNote 前端保留统一路由、auth、workspace 和 FileTree/resource picker;后端通过 MNote canonical API 转接 WeKnora KB/doc/status/search。成本高于 iframe,但冲突最少。 -2. **可选:抽取 WeKnora Vue 组件进入独立 adapter bundle**。仅抽知识库列表、详情、上传、状态时间线组件;替换其 auth/client/router/store,数据仍走 MNote 后端。 -3. **不选:iframe WeKnora 全量前端**。会带来 WeKnora 登录、租户切换、独立上传文件真相和打开页面冲突,只能作为调试入口,不作为产品主线。 - -知识库最小数据模型应分成 `mnote_knowledge_bases` 与 `mnote_knowledge_sources` 两层: - -```text -mnote_knowledge_bases - id - user_id - workspace_id - root_uri - name - description - provider = weknora - provider_kb_id - default_tool_enabled - metadata_json - created_at / updated_at / archived_at -``` - -`mnote_knowledge_bases` 是用户可见的知识库集合;`mnote_knowledge_sources` 是 source 到 provider knowledge/chunk 的映射。删除 source 只删除索引和映射,不删除本地原文件;删除 KB 默认只归档 MNote KB 与删除/禁用对应 WeKnora provider KB,不能批量删除 MNote 本地文件。 - -### 5.5 Source Registry provider-neutral 化 - -旧 LightRAG 字段应迁移为 provider-neutral registry;当前 JSON 文件 `lightrag-source-registry.json` 中的 `lightRagDocId/lightRagStatus/lightRagFilePath/symlinkPath` 等字段必须兼容读取并迁移或双写一段时间。目标结构为: - -```text -mnote_knowledge_sources - id - user_id - workspace_id - root_uri - resource_id nullable - source_uri - source_path - source_hash - provider = weknora | lightrag_legacy | local_fallback - provider_kb_id - provider_knowledge_id - provider_doc_id nullable - provider_status - index_status = queued | parsing | indexed | failed | stale | deleted - title - source_type - tags_json - citation_locator_json - metadata_json - created_at / updated_at / deleted_at -``` - -关键原则:provider 返回的 `knowledge_filename` / `chunk_id` 不能直接当 MNote 文件真相,必须回查 registry 映射到 `resource_id/source_uri/open_reference`。 - -## 6. 页面打开 / 文件打开 / 引用回跳融合 - -### 6.1 冲突 - -- MNote 自己就是 FileManager:resource tree / file tree / document pane / resource tab 是唯一页面/文件打开入口。 -- OpenHub 第一阶段只借用多用户 AI 能力和 AI 页面产品参考,不使用 OpenHub FileManager 或完整前端。 -- WeKnora 只暴露 MCP/CLI/API 知识工具,返回 knowledge/chunk/reference,不参与页面打开。 - -因此这里不需要做 OpenHub FileManager、WeKnora 全量 Web UI 或 provider path 的打开融合;只需要保证 MNote Page AI 回复中的 changed file、diff、citation、tool result 能映射回 MNote 页面。注意:这里的“不使用 WeKnora Web UI”只针对 Page AI 对话壳、引用打开链路和文件打开链路;知识库管理页面仍按 5.4 复用 WeKnora KB 页面体验,但必须运行在 MNote shell 与 MNote 权限/source registry 后面。 - -### 6.2 决策 - -所有打开动作只发生在 MNote Page AI / document pane 内: - -- changed file chip 点击:`window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`。 -- 当前页被修改:`window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument()` 或 watcher 链路。 -- WeKnora citation / MCP/CLI tool result 点击:`/api/knowledge-rag/open-reference` 返回 MNote locator,再由 MNote 前端打开。 -- OpenHub session 中出现的 path/diff/tool reference 只作为数据来源,渲染和点击由 MNote Page AI 处理。 -- 不接 OpenHub FileManager,不使用 OpenHub `/api/files` 作为页面读写入口。 -- 不在 Page AI / 引用打开链路嵌入 WeKnora 全量 Web UI,不让 WeKnora 决定打开哪个 MNote 页面。 - -### 6.3 MNote Page AI open reference payload - -```json -{ - "provider": "weknora", - "providerKbId": "kb-...", - "providerKnowledgeId": "...", - "providerChunkId": "...", - "sourceId": "mnote-source-...", - "rootUri": "file:///...", - "sourcePath": "docs/a.md", - "locator": { - "kind": "markdown-range", - "heading": "...", - "lineStart": 12, - "lineEnd": 20, - "quote": "..." - } -} -``` - -后端必须二次校验:当前用户、rootUri、workspace、source ownership、allowed read scope。这个 payload 只服务 MNote Page AI 中的点击打开,不是 OpenHub FileManager 或 WeKnora 前端合同。 - -## 7. OpenHub FastAPI / Redis / opencode Runtime 安排 - -### 7.1 核心边界 - -这里不应让 MNote 重写 OpenHub FastAPI 的 opencode client。最小干扰路径是运行 OpenHub AI 后端栈,让 MNote 只做宿主、scope 注入、入口裁剪和回跳桥: - -```text -MNote Page AI host - -> MNote 校验登录态、workspace、rootUri、allowed roots - -> 注入/映射 OpenHub user workspace + session/skill/tool scope - -> OpenHub AI UI - -> OpenHub FastAPI + SQLite session/message + Redis cache - -> OpenHub opencode client - -> opencode serve /global/event /session/{id}/prompt_async /diff -``` - -- **OpenHub FastAPI 继续运行**:负责 AI session/message、skill、tool permission、opencode client、event stream、diff/changed files 等 OpenHub 原生能力。 -- **不要把待补能力写成已存在能力**:OpenHub 源码已确认有 session/message、skill、tool/model permission、SmartEntity、Git snapshot、FileManager、KnowledgeManager 和 opencode event/diff 链路;未确认独立 MCP 管理 API/UI。MCP 第一阶段应由 MNote 注册 WeKnora CLI/MCP/API tool 到 OpenHub/opencode,而不是假设 OpenHub 已有完整 MCP 管理面。 -- **Redis 跟随 OpenHub stack**:OpenHub Redis 主要用于 token/rate-limit/cache/临时态;OpenHub session/message 真相仍在 SQLite。MNote 不把 Redis 当自己的消息、权限、文件或知识库真相。 -- **MNote 不重写 planner/client**:MNote 不替 OpenHub 判断何时调用 WeKnora,也不重写 opencode prompt/event/diff 流程。 -- **MNote 只裁剪不用入口**:OpenHub Login、FileManager、KnowledgeManager、Admin 等页面不暴露;必要时在 proxy 层 404、隐藏菜单或跳转到 MNote 对应页面。 - -### 7.2 FastAPI 的具体作用 - -OpenHub FastAPI 在本设计中保留以下作用: - -- 管理 OpenHub conversation/session/message/history。 -- 管理 skill、agent/SmartEntity、tool/model permission。 -- 调用 `opencode serve`,包括创建 session、发送 prompt、监听 `/global/event`、读取 diff。 -- 向 OpenHub AI 前端提供消息流、tool card、diff、changed files、history 等 API。 -- 读取由 MNote 注入的 workspace directory、user scope、allowed roots、WeKnora CLI/MCP/API tool 配置。 - -OpenHub FastAPI 不保留以下作用: - -- 不作为 MNote 登录入口。 -- 不接管 MNote resource tree / FileTree / document pane。 -- 不启用 OpenHub 自带 KnowledgeManager 作为知识库 UI。 -- 不启用 OpenHub 自带轻量 knowledge tables 作为 RAG 底座。 -- 不执行 Git snapshot / restore / revert,除非未来另开设计并经 MNote 显式确认。 - -### 7.3 Redis 安排 - -- OpenHub 需要的 Redis/cache/临时态由 OpenHub deployment 管理,随 OpenHub FastAPI 启停和 health check;OpenHub `REDIS_HOST/REDIS_PORT/REDIS_DB` 应独立配置,避免误连 WeKnora Redis DB。 -- WeKnora 有自己的 Redis/Asynq/Langfuse 相关依赖,由 WeKnora stack 管理;MNote 只检查 WeKnora health、必要 API 与 `weknora mcp serve`/CLI profile 是否可用。 -- MNote control-plane 不依赖 Redis 保存登录授权、workspace grants、source registry、open-reference 或文件真相。 -- Redis 中只允许放可重建状态:缓存、队列、临时流状态;不能成为用户权限、文件内容、知识库 source registry 或 Page AI 消息的唯一持久真相。 -- 本机开发阶段允许共用一个 Redis 进程,但必须使用独立 DB/前缀:OpenHub、WeKnora、Langfuse/worker queue 不得混用 keyspace;`dev-hot` health 输出应显示各自 Redis 连接目标。 - -### 7.4 Page AI context - -MNote 注入给 OpenHub / opencode 的 context: - -```text -- 当前 MNote 用户/匿名显示名,不含敏感 cookie/token -- 当前 workspace/rootUri -- 当前页面标题、真实 Markdown path、resource id -- selection 摘要 -- allowed roots / write constraints -- OpenHub session scope / skill scope / tool permission scope -- WeKnora tool scope:允许查询的 kb ids/source ids/citation policy -- MNote 文件打开/刷新 bridge usage -``` - -不要注入整篇正文;路径与 allowed roots 足够让 opencode 在本地读取文件。正文只在 selection 或用户明确需要时作为有限上下文传入。 - -## 8. UI 边界:嵌入 OpenHub AI 面板,保留 AI 原能力 - -### 8.1 Page AI Shell - -MNote sidebar host 的职责应尽量薄:承载 OpenHub AI 面板、注入 MNote context、处理 MNote 回跳,不重新设计一套顶部/侧边栏产品结构。 - -- 顶部:第一阶段可以不要 MNote 自定义顶部;如需状态,只做极简 host 状态条或错误提示,避免覆盖 OpenHub AI 面板原有布局。 -- 主体:OpenHub AI 面板,由 OpenHub 前端/后端处理消息、tool card、diff、history、agent/skill/tool 状态。 -- 侧边:优先保留 OpenHub AI 相关侧边能力,包括历史 session、skill、agent/SmartEntity、tool/model permission 等设置;MCP 若当前 OpenHub 前端没有原生管理页,第一阶段显示 WeKnora MCP/CLI tool 连接状态与 MNote scope,而不是重写完整 MCP 管理器。 -- MNote context:以 context pills / hidden bootstrap / postMessage / proxy header 方式注入当前 page path、rootUri、selection、allowed roots,不强行改 OpenHub UI 主结构。 -- 回跳:changed file、citation、reference 点击时走 MNote bridge 打开页面。 - -### 8.2 OpenHub 前端裁剪策略 - -第一阶段不是“大面积禁用 OpenHub 前端”,而是**保留 AI 面板相关能力,只掐断与 MNote 真相冲突的入口**: - -保留: - -- AI chat 主界面。 -- history / session 抽屉。 -- skill / agent 设置。 -- WeKnora CLI/MCP tool 的连接状态;若 OpenHub 无原生 MCP 设置 UI,则通过 MNote host 或 OpenHub 最小扩展显示,不阻塞 AI 主界面。 -- tool/model permission UI。 -- tool card、diff、changed files、运行日志等 AI 运行态 UI。 - -裁剪或转接: - -- OpenHub Login:禁用,改由 MNote 登录态注入 OpenHub 派生用户。 -- OpenHub workspace selector:禁用或固定为 MNote 授权 rootUri 派生 workspace。 -- OpenHub FileManager:不作为文件真相;若 AI 面板内出现文件入口,转接到 MNote document pane / FileTree。 -- OpenHub KnowledgeManager:不作为知识库 UI;如入口存在,转接到 MNote 知识库展示层或隐藏。 -- OpenHub Admin / Team / Scheduler / SmartEntity:默认隐藏或不可达,除非后续明确纳入 Page AI 管理面。 -- OpenHub Git snapshot / restore:默认关闭,避免改写 MNote local-first workspace 版本语义。 - -### 8.3 MNote 暴露给 OpenHub 的边界能力 - -MNote 不替 OpenHub 判断何时调用知识库、何时用 skill/tool/MCP 工具、如何组织 tool call;这些交给 OpenHub/opencode 已有 skill/tool/agent 规范处理,并由 MNote 注册的 WeKnora CLI/MCP/API tool 提供知识能力。MNote 只提供最小边界能力: - -```text -- 当前页面 context:page path / title / selection / rootUri / allowed roots -- OpenHub scope:user/workspace/rootUri 派生的 session/skill/tool permission scope -- WeKnora CLI/MCP/API 配置:以 skill/tool/MCP 工具形式注册给 OpenHub/opencode -- open-reference bridge:把 provider citation/chunk/source 映射成 MNote 页面打开动作 -- changed-file bridge:把 OpenHub/opencode 返回的 path 映射成 MNote document pane 打开/刷新 -``` - -也就是说,MNote 是宿主、授权边界和回跳桥,不是 OpenHub agent 的 planner,也不是 OpenHub AI 面板的重写者。 - -## 9. 数据流:OpenHub 原生执行,MNote 注入边界 - -### 9.1 发送 Page AI 消息 - -```text -用户输入 - -> MNote Page AI host 中的 OpenHub AI 面板 - -> OpenHub 前端调用 OpenHub FastAPI - -> OpenHub FastAPI 使用 OpenHub session/message/skill/tool permission 与 MNote 注册的 WeKnora tool scope - -> OpenHub FastAPI 调 opencode serve - -> opencode 读写 MNote 授权 rootUri 内文件 - -> OpenHub session/message/tool history 持久化 - -> OpenHub AI UI 渲染回复、tool card、diff、changed files - -> MNote bridge 只处理文件打开、刷新、citation 回跳 -``` - -MNote 不在消息主链里重写 OpenHub planner,也不解析知识需求后替 OpenHub 决定调用 WeKnora。MNote 只负责登录态、workspace 授权、scope 注入和结果回跳。 - -### 9.2 知识检索 - -```text -OpenHub/opencode 判断需要知识 - -> 按 skill/tool/MCP 规范调用已注册的 WeKnora 工具 - -> WeKnora MCP/CLI/API 返回 chunks/references - -> OpenHub/opencode 把结果纳入当前 session/tool result - -> 用户点击 citation/reference 时 - -> MNote open-reference bridge 按 source registry / allowed roots 映射并打开对应页面 -``` - -MNote 不负责替 OpenHub 判断 knowledge scope;scope 在注册 WeKnora CLI/MCP/skill/tool 时由 MNote 根据当前用户、workspace、allowed roots 预先约束。 - -### 9.3 知识库生成与展示 - -```text -MNote 知识库展示层上传/添加资料 - -> MNote canonical knowledge API - -> MNote 校验 user/workspace/rootUri/write permission - -> WeKnora file/manual/url ingest - -> 写 mnote_knowledge_sources registry - -> FileTree/Knowledge UI 显示 indexing 状态 - -> 生成/更新可供 OpenHub/opencode 使用的 WeKnora CLI/MCP/skill/tool 配置 -``` - -OpenHub 不管理知识库生成页面;它只消费已经按 MNote 授权边界配置好的 WeKnora 工具。知识库生成页面复用 WeKnora KB 页面体验,但 source 选择应以 MNote 本地文件/文件夹集合为入口。 - -## 10. 可执行 Checklist - -本节保留主线阶段清单;逐文件、逐接口、逐 smoke 的细化执行项见 `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md`。实施时先完成配套清单 P0/P1,再回填本文状态;不要把 P2/P3 优化提前混入 MVP。 - - -### 10.1 设计与旧路径冻结 - -- [x] 在 `7-65` 标注官方 opencode iframe 只保留为 fallback,不再作为 Page AI 产品主线。 -- [x] 在 `7-66` 标注自研 native UI 只保留为 fallback,不再继续扩自研聊天框。 -- [x] 在 `7-67` 标注已被本文覆盖:OpenHub 路线从“参考/重写”改为“嵌入 AI 面板 + 保留 FastAPI/Redis/opencode client”。 -- [x] 将本文保留在 `design/07-ai/process/`,作为当前 Page AI 嵌入式集成主设计。 -- [x] 在本轮授权写入范围内统一术语:WeKnora 是唯一知识库底座,MNote 是知识库展示和文件真相层,OpenHub KnowledgeManager 不作为知识库页;bugs/testing 文档未在本任务授权写入范围内修改。 -- [x] 在本轮授权写入范围内搜索并标记仍把 LightRAG 描述为默认知识库 provider 的文案,改成 legacy/fallback。 - -#### 10.1 证据 - -- `design/07-ai/process/7-65-opencode-webui-embed-page-ai-v1.md`:已冻结为官方 opencode WebUI iframe fallback;上下文口径从 LightRAG 引用改为 WeKnora 引用,旧 LightRAG 仅 legacy/fallback。 -- `design/07-ai/process/7-66-opencode-native-page-ai-ui-v1.md`:已冻结为 native UI fallback / debug receipt,不再扩写自研聊天框。 -- `design/07-ai/process/7-67-openhub-page-ai-fusion-v1.md`:已标注被本文覆盖,路线改为嵌入 OpenHub AI 面板并保留 OpenHub FastAPI/Redis/opencode client。 -- `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-v1.md`:保留在 `process/`,作为当前 Page AI 嵌入式集成主设计;10.2-10.9 后续执行细化到 `design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md`。 - -### 10.2 OpenHub 服务栈接入 - -- [ ] 确认 OpenHub 本机源码路径、启动命令、依赖文件和默认端口。 -- [ ] 确认 OpenHub FastAPI Redis 的真实用途:token/rate-limit/cache/临时态;记录 Redis host/port/db/env 和启动顺序,避免写成消息真相或队列主链。 -- [ ] 确认 OpenHub FastAPI 调 opencode 的配置项:opencode base URL、directory 参数、BasicAuth、模型/provider env。 -- [ ] 确认 OpenHub 自动 Git snapshot 的触发点,并通过配置/补丁关闭 `stream.py`、`task_executor.py` 中的自动 snapshot 写链。 -- [ ] 在 `scripts/desktop-hot.js` 或等价 dev-hot 链路中增加 OpenHub FastAPI、Redis、opencode serve 的启动/跳过/health check。 -- [ ] 增加 OpenHub health endpoint 探针;失败时错误信息区分 FastAPI、Redis、opencode。 -- [ ] 禁用 OpenHub launcher 的 kill-port 或 destructive workspace 行为,避免影响 MNote dev 进程。 -- [ ] 明确 OpenHub Git snapshot / restore / revert 默认关闭,并在启动环境或配置中落实。 -- [ ] 写 smoke:OpenHub FastAPI 可列 session,Redis 可达,opencode serve 可达。 - -### 10.3 MNote 登录态到 OpenHub Scope - -- [ ] 定义 `openhub_user_key = stable_hash(mnote_user_id)`。 -- [ ] 定义 `openhub_workspace_key = stable_hash(workspace_id, root_uri)`。 -- [ ] 定义 session scope:`mnote_user_id + workspace_id + root_uri + page_resource_id`。 -- [ ] 在 MNote 后端实现或扩展 OpenHub host/proxy bootstrap endpoint,输出 user/workspace/session/tool scope。 -- [ ] 禁止前端持有 OpenHub JWT/localStorage 登录真相;OpenHub 用户态由 MNote 后端注入或代理。 -- [ ] rootUri / workspace 切换时新建或切换 OpenHub session,旧 session 标记 stale/archived。 -- [ ] 不同 MNote 用户访问同一页面时不得复用同一 OpenHub session、skill scope、tool permission scope 或 WeKnora tool scope。 -- [ ] 写 control-plane 测试:binding 受 user/workspace/rootUri 隔离,跨用户查询失败。 - -### 10.4 OpenHub AI 面板嵌入 - -- [ ] 在 MNote Page AI sidebar host 中选择 iframe 或 reverse proxy 嵌入方式。 -- [ ] 只暴露 OpenHub AI 页面路由;Login/Admin/Team/Scheduler/SmartEntity 默认不可达。 -- [ ] OpenHub workspace selector 固定到 MNote 授权 rootUri 派生 workspace。 -- [ ] 保留 OpenHub AI 主界面、history/session、skill/agent 设置、tool/model permission、tool card、diff、changed files、运行日志;MCP 管理面如源码不存在,不把它作为已完成 OpenHub UI 依赖。 -- [ ] FileManager 入口若出现在 AI 面板内,跳转 MNote document pane / FileTree 或禁用。 -- [ ] KnowledgeManager 入口若出现在 AI 面板内,跳转 MNote WeKnora 知识库页或隐藏。 -- [ ] MNote context 通过 postMessage、proxy header 或 bootstrap JSON 注入 page path、title、selection、rootUri、allowed roots。 -- [ ] 写浏览器 smoke:Page AI 显示 OpenHub AI 面板,刷新后 session/history 仍可恢复。 -- [ ] 写浏览器 smoke:访问 OpenHub Login/Admin/FileManager/KnowledgeManager 非 AI 入口不会接管 MNote。 - -### 10.5 OpenHub 原生 opencode 链路 - -- [ ] 保持 OpenHub FastAPI 调用 `opencode serve`,MNote 不重写 prompt/event/diff client。 -- [ ] 确认 OpenHub 创建 session 时 directory 固定为 MNote 授权 rootUri。 -- [ ] 确认 OpenHub 发送 prompt 后可监听 `/global/event` 并渲染 tool card / assistant message。 -- [ ] 确认 OpenHub 可读取 `/session/{id}/diff` 或等价 changed files。 -- [ ] MNote 只读取 changed file / diff artifact 的 path 和 session id,用于 open/refresh。 -- [ ] changed file 点击调用 `window.__mnoteDocumentPaneRuntime.openResourceInActiveTab()`。 -- [ ] 当前打开页面被修改后走 watcher 或 `refreshPrimaryDocument()` 刷新。 -- [ ] 写真实 smoke:让 OpenHub AI 修改 rootUri 内 Markdown,MNote 当前页面可看到变更。 - -### 10.6 WeKnora 知识库页面替换 - -- [ ] 盘点 WeKnora `KnowledgeBaseList.vue`、`KnowledgeBase.vue`、`KnowledgeBaseEditorModal.vue`、`knowledge-processing-timeline.vue` 的依赖。 -- [ ] 决定复用方式:优先 MNote adapter 页面复刻 WeKnora 交互;可选抽组件;不采用 iframe WeKnora 全量 frontend route 作为产品主线。 -- [ ] 知识库定义为 MNote 授权文件/文件夹/page resource 集合,不创建新的文件内容真相。 -- [ ] 建立 source set 模型:kb id、workspace id、rootUri、source path/resource id、provider kb id、provider knowledge id、source hash。 -- [ ] source 选择 UI 接 MNote FileTree / resource picker,而不是 WeKnora 自己的独立文件真相。 -- [ ] 入库时 MNote 先校验 allowed roots,再调用 WeKnora file/manual/url ingest。 -- [ ] WeKnora processing / reparse / failed / indexed 状态同步到 MNote registry 与 FileTree 灯号。 -- [ ] 删除 source 时只删除知识库索引和 registry 映射,不删除本地原文件。 -- [ ] 写浏览器 smoke:创建 KB、添加本地文件夹、看到 indexing 状态、完成后可检索。 - -### 10.7 WeKnora 检索替换 LightRAG - -- [ ] 抽出 `KnowledgeProvider` 或等价 provider boundary,新增 `weknora` 实现。 -- [ ] `/api/knowledge-rag/status` 改为检查 WeKnora health、KB 映射、source registry 状态。 -- [ ] `/api/knowledge-rag/ingest` 改为 WeKnora ingest,并写入 provider KB / knowledge / source hash。 -- [ ] `/api/knowledge-rag/search` 改为 WeKnora `/api/v1/knowledge-search`;单 KB 调试/CLI/MCP 可走 `/api/v1/knowledge-bases/:id/hybrid-search`。 -- [ ] `/api/knowledge-rag/query` 第一阶段由 WeKnora search results + citations 组成 query result,不启用 WeKnora chat session。 -- [ ] `/api/knowledge-rag/section-context` 改为基于 WeKnora chunk + MNote 本地 locator,不读 LightRAG sidecar。 -- [ ] `open-reference` 从 WeKnora `knowledge_id/chunk_id/knowledge_base_id` 回查 MNote registry,再生成 MNote locator;保留 `content/matched_content/match_type/metadata/chunk_metadata/image_info/parent_chunk_id/sub_chunk_id` 供 citation 与诊断。 -- [ ] WeKnora RRF score 不沿用 LightRAG 阈值;UI 只显示排序分或弱化分值解释。 -- [ ] 保留旧 LightRAG provider 为 legacy fallback,但默认隐藏且不作为 smoke 基线。 -- [ ] 更新现有 knowledge-rag smoke,把 provider 断言从 `lightrag` 改为 `weknora`。 -- [ ] 写单元测试:WeKnora SearchResult 映射为 MNote search result / citation / open-reference。 - -### 10.8 WeKnora MCP/CLI Tool Bridge - -- [ ] 第一阶段 MCP surface 选定 Go CLI `weknora mcp serve` 或 MNote facade 包装后的等价只读工具面;Python `mcp-server` 只作为后续写能力参考,不默认暴露给 OpenHub/opencode。 -- [ ] 默认暴露只读工具:`mnote.weknora.search`、`mnote.weknora.list_sources`、`mnote.weknora.get_source_status`、`mnote.weknora.open_reference`。 -- [ ] 写工具 manifest / skill,使 OpenHub/opencode 能在当前 session scope 内调用 WeKnora。 -- [ ] tool 调用前注入 KB/source allowlist,不让 OpenHub/opencode 查询未授权 source。 -- [ ] tool result 返回 provider ids、quote、chunk metadata、MNote open-reference token。 -- [ ] citation 点击由 MNote bridge 打开,不由 OpenHub 或 WeKnora 决定本地路径。 -- [ ] 写真实 smoke:OpenHub AI 通过 MCP/CLI tool 查询 WeKnora,回答中出现可回跳 citation。 - -### 10.9 dev-hot 与真实验收 - -- [ ] `npm run dev:hot` 启动或检查 MNote、OpenHub FastAPI、Redis、opencode、WeKnora。 -- [ ] 登录 `mnote.e2e@example.com`,确认没有 OpenHub/WeKnora 登录跳转。 -- [ ] 打开真实 Markdown 页面并打开 Page AI。 -- [ ] Page AI 嵌入 OpenHub AI 面板,history/session/skill/agent/tool permission 入口仍可用;WeKnora MCP/CLI tool 状态可见。 -- [ ] OpenHub Login/Admin/FileManager/KnowledgeManager 非 AI 入口被隐藏、404 或跳转 MNote。 -- [ ] 发送消息后 OpenHub session/message/history 持久化,刷新浏览器可恢复。 -- [ ] 让 AI 修改当前 rootUri 内 Markdown,MNote document pane 可刷新并显示变更。 -- [ ] 在 WeKnora 知识库页用本地文件/文件夹集合建库,完成 ingest/index。 -- [ ] OpenHub AI 通过 WeKnora MCP/CLI/API tool 检索该 KB,并返回 citation。 -- [ ] 点击 citation 打开 MNote 对应页面或资源 tab。 -- [ ] 保存 smoke 输出和关键截图;失败时标明 OpenHub FastAPI、Redis、opencode、WeKnora、MNote bridge 中哪一层失败。 - -## 11. 取舍矩阵 - -| 冲突点 | 直接用 OpenHub | 直接用 WeKnora | MNote 边界方案 | 决策 | -|---|---|---|---|---| -| 登录 | 第二套 JWT/localStorage | 第二套 tenant login/API key | MNote cookie -> scope/binding | 选 MNote 登录入口 + OpenHub 派生 scope | -| 消息历史 | OpenHub conversation/session | WeKnora chat session 暂弃用 | MNote binding + artifact index | 选 OpenHub session 为真相,MNote 只做绑定/索引 | -| 知识库 UI | OpenHub KnowledgeManager 暂不使用 | WeKnora KB list/detail/upload/status 体验可复用 | MNote shell + WeKnora KB UI adapter | 选 WeKnora 知识库页面体验,嵌入 MNote 外壳 | -| 知识库底座 | OpenHub 自带知识库暂不用 | 强 RAG/Wiki/Graph | MNote registry + WeKnora | 选 WeKnora | -| 文件打开 | workspace path | knowledge filename | MNote resource/open-reference | 选 MNote | -| 权限 | 模型/工具/skill;MCP 由工具配置承接 | tenant RBAC | MNote scope + OpenHub/opencode 权限规范 + provider 防线 | 组合,以 MNote scope 为边界 | -| Agent 编辑 | opencode | WeKnora agent-chat 暂弃用 | opencode 编辑 + WeKnora CLI/MCP/API 知识工具 | 组合 | -| UI 成本 | 嵌入 AI 面板,裁剪非 AI 入口 | 不适合 Page AI 编辑侧栏 | MNote host + OpenHub AI 面板 | 选 OpenHub AI 面板嵌入 | -| 运维 | OpenHub FastAPI + Redis + opencode 运行 | WeKnora 服务 | MNote 启动/检查 OpenHub/opencode/Redis/WeKnora,提供 binding/tool 配置 | 选 OpenHub 栈运行 + MNote 边界控制 | - -## 12. 风险与防线 - -### 12.1 风险:三套权限漂移 - -防线:MNote 是浏览器入口和授权边界;OpenHub/opencode 只能拿到 MNote 派生 scope、allowed roots、tool/CLI/MCP 配置,不直接获得未过滤 workspace/rootUri。 - -补充:WeKnora RBAC 只能作为 provider 防线,不作为 MNote 授权来源;RBAC 关闭、API key 复用或共享空间变化时,MNote 过滤结果仍必须保持一致。 - -### 12.2 风险:WeKnora citation 无法定位到本地文件 - -防线:ingest 时必须写 `source_uri/source_hash/provider_knowledge_id`;检索返回后以 provider id 回查 registry,失败时 UI 标记“定位降级”,不能伪造路径。 - -### 12.3 风险:OpenHub UI 迁移成本变成 fork - -防线:第一阶段嵌入 OpenHub AI 面板,尽量保持 OpenHub 已有 AI 功能不动;只对非 AI 入口通过隐藏、404、反代拦截或跳转 MNote 做最小裁剪。后期如需精简或替换组件,再单独做 UI 迁移设计。 - -### 12.4 风险:opencode 工作目录过大导致找不到文件 - -防线:工作目录固定为当前打开 rootUri;context 中传相对 path、allowed roots、当前页面 path;切换 rootUri 新开 session;不把大仓根或 recycle 目录作为默认工作目录。 - -### 12.5 风险:旧 LightRAG 残留误导 - -防线:UI 文案和配置改 provider-neutral;默认 provider 改 WeKnora;旧 LightRAG tools/manifest 标记 legacy 或隐藏。 - -### 12.6 风险:OpenHub 自动 Git snapshot 污染 workspace - -防线:只参考 OpenHub AI 页面与 event/diff 解析思路;不迁移 Git snapshot / restore 后端路径。若以后需要版本恢复,必须走 MNote DocumentBuffer / watcher / 显式用户确认的版本策略。 - -### 12.7 风险:MCP/CLI 写权限边界不清 - -防线:WeKnora 有 Go CLI `weknora mcp serve` 与 Python `mcp-server` 两套 surface,读写能力不同。MNote 接入前必须指定采用哪一套、默认只读还是允许 create/delete,并把写操作纳入 MNote 权限审批。 - -## 13. 验收标准 - -MVP 通过必须同时满足: - -- 浏览器中 Page AI 嵌入 OpenHub AI 面板,并能显示 OpenHub 原生消息/tool/diff/history。 -- MNote 登录态是唯一登录入口;无 OpenHub/WeKnora 登录跳转。 -- 同一 MNote 用户跨浏览器可恢复绑定到当前页面/rootUri 的 OpenHub session。 -- opencode 在当前 rootUri 下能真实读取/修改 Markdown。 -- changed file chips 与 DiffViewer path 能用 MNote 打开。 -- MNote 知识库页面已由 `MNote shell + WeKnora KB adapter` 替换简陋页:可创建/列出/查看 KB,可从 MNote FileTree/resource picker 添加本地文件或文件夹,可显示 WeKnora processing/indexed/failed 状态。 -- `/api/knowledge-rag/status/search/query/section-context/open-reference/delete-source` 默认 provider 为 `weknora`;旧 LightRAG 只作为 `lightrag_legacy` 隐藏 fallback,不再作为默认 smoke 基线。 -- WeKnora search/MCP/CLI 工具返回真实引用,citation 经 registry 映射后可回跳 MNote 文件;失败时明确定位降级,且不能把 `knowledge_filename` 伪装成本地路径。 -- OpenHub/opencode 通过 MNote 注册的 WeKnora tool scope 调用知识库;MNote 不替 OpenHub 做 planner 判断,只限制 KB/source allowlist 与 open-reference。 -- `npm run dev:hot` 能拉起并检查必要 runtime;失败时错误页给出 OpenHub FastAPI、Redis、opencode、WeKnora 或 OpenHub session binding 哪个不可达。 -- MNote Page AI 路径不触发 OpenHub 登录跳转;OpenHub FileManager/KnowledgeManager/Admin 等非 AI 入口被隐藏、404 或跳转 MNote;消息真相保留在受 MNote scope 隔离的 OpenHub session 中,不触发 Git snapshot/restore 自动写链。 - -## 14. 本轮源码依据 - -- OpenHub 源码:`/tmp/mnote-openhub-research/OpenHub`(当前无 `.codegraph/`,本轮以 targeted `find/rg/sed` 复核) -- WeKnora 本机部署:`/mnt/Data1T/Mnote_data/weknora/WeKnora` -- MNote Page AI runtime:`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` -- MNote document pane bridge:`rust/crates/mnote-web/browser/document-editor-adapter-runtime.js` -- MNote opencode route:`rust/crates/mnote-web/src/routes/page_ai_opencode.rs` -- MNote knowledge route:`rust/crates/mnote-web/src/routes/knowledge_rag.rs` -- MNote auth/session route:`rust/crates/mnote-web/src/routes/session.rs`、`rust/crates/mnote-web/src/routes/gateway.rs` - -- OpenHub FastAPI 入口:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/main.py` -- OpenHub opencode client / launcher:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/opencode_client.py`、`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/opencode_launcher.py` -- OpenHub streaming / Git snapshot 触发:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/stream.py`、`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/services/task_executor.py` -- OpenHub session/auth/files/knowledge/admin:`/tmp/mnote-openhub-research/OpenHub/smart-query-backend/app/api/session.py`、`auth.py`、`files.py`、`knowledge.py`、`admin.py` -- WeKnora SearchResult / hybrid-search client:`/mnt/Data1T/Mnote_data/weknora/WeKnora/client/knowledgebase.go` -- WeKnora MCP CLI:`/mnt/Data1T/Mnote_data/weknora/WeKnora/cli/internal/mcp/tools.go`、`/mnt/Data1T/Mnote_data/weknora/WeKnora/cli/cmd/mcp/serve.go` -- WeKnora KB UI:`/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/` diff --git a/recycle/20260712-pi-ts-openhub-retirement/rust/crates/mnote-web/src/routes/page_ai_openhub.rs b/recycle/20260712-pi-ts-openhub-retirement/rust/crates/mnote-web/src/routes/page_ai_openhub.rs deleted file mode 100644 index 3f852a89..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/rust/crates/mnote-web/src/routes/page_ai_openhub.rs +++ /dev/null @@ -1,1351 +0,0 @@ -use crate::app::AppState; -use crate::context::RequestContext; -use crate::error::WebError; -use axum::body::{Body, Bytes}; -use axum::extract::{OriginalUri, Path, State}; -use axum::http::header::{self, HeaderName}; -use axum::http::{HeaderMap, Method, StatusCode, Uri}; -use axum::response::Response; -use axum::{Extension, Json}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use control_plane::session_token_hash; -use futures_util::TryStreamExt; -use serde::Deserialize; -use serde_json::{json, Value}; -use std::collections::hash_map::DefaultHasher; -use std::fs; -use std::hash::{Hash, Hasher}; -use std::path::PathBuf; -use std::time::Duration; -use std::time::{SystemTime, UNIX_EPOCH}; - -const COOKIE_MNOTE_SESSION: &str = "mnote_session"; -const DEFAULT_OPENHUB_BASE_URL: &str = "http://127.0.0.1:18080"; -const DEFAULT_OPENCODE_BASE_URL: &str = "http://127.0.0.1:4096"; -const DEFAULT_WEKNORA_ENDPOINT: &str = "http://127.0.0.1:8080/api/v1"; -const OPENHUB_AI_ENTRY_PATH: &str = "/page-ai/openhub/ai"; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenHubBootstrapRequest { - page_id: Option, - workspace_id: Option, - page_title: Option, - page_path: Option, - page_absolute_path: Option, - root_uri: Option, - selection: Option, - allowed_roots: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenHubArtifactIndexQuery { - #[serde(alias = "openhub_session_id")] - openhub_session_id: Option, - root_uri: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenHubArtifactIndexUpsertRequest { - #[serde(alias = "openhub_session_id")] - openhub_session_id: String, - kind: String, - #[serde(alias = "provider_id")] - provider_id: Option, - path: Option, - #[serde(alias = "citation_payload")] - citation_payload: Option, - #[serde(alias = "root_uri")] - root_uri: Option, - #[serde(alias = "workspace_id")] - workspace_id: Option, - #[serde(alias = "page_resource_id")] - page_resource_id: Option, -} - -fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { - let has_actor = context.auth.actor_id.trim() != "anonymous"; - if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { - return Ok(()); - } - Err(WebError::new( - StatusCode::UNAUTHORIZED, - "page_ai_openhub_unauthorized", - "Page AI OpenHub host 需要 MNote 登录态后访问", - ) - .with_context(context)) -} - -fn raw_cookie_value(cookies: &str, name: &str) -> Option { - cookies.split(';').find_map(|part| { - let mut pieces = part.trim().splitn(2, '='); - let key = pieces.next()?.trim(); - let value = pieces.next()?.trim(); - (key == name && !value.is_empty()).then(|| value.to_string()) - }) -} - -fn stable_hash(prefix: &str, value: &str) -> String { - let mut hasher = DefaultHasher::new(); - value.hash(&mut hasher); - format!("{prefix}_{:016x}", hasher.finish()) -} - -fn now_ms() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or(0) -} - -fn openhub_base_url() -> String { - std::env::var("MNOTE_OPENHUB_BASE_URL") - .ok() - .map(|value| value.trim().trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_OPENHUB_BASE_URL.to_string()) -} - -fn opencode_base_url() -> String { - std::env::var("MNOTE_OPENCODE_BASE_URL") - .ok() - .map(|value| value.trim().trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_OPENCODE_BASE_URL.to_string()) -} - -fn weknora_endpoint() -> String { - std::env::var("MNOTE_WEKNORA_ENDPOINT") - .or_else(|_| std::env::var("WEKNORA_ENDPOINT")) - .ok() - .map(|value| value.trim().trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_WEKNORA_ENDPOINT.to_string()) -} - -fn weknora_api_key() -> Option { - std::env::var("MNOTE_WEKNORA_API_KEY") - .ok() - .or_else(|| std::env::var("WEKNORA_API_KEY").ok()) - .map(|value| value.trim().trim_matches('"').to_string()) - .filter(|value| !value.is_empty()) -} - -fn join_url(base_url: &str, path: &str) -> String { - format!( - "{}/{}", - base_url.trim_end_matches('/'), - path.trim_start_matches('/') - ) -} - -fn service_status( - name: &str, - base_url: String, - health_path: &str, - configured: bool, - reachable: bool, - http_status: Option, - error: Option, -) -> Value { - let status = if reachable { - "ready" - } else if configured { - "degraded" - } else { - "not_configured" - }; - json!({ - "name": name, - "status": status, - "configured": configured, - "reachable": reachable, - "baseUrl": base_url, - "healthPath": health_path, - "httpStatus": http_status, - "error": error, - }) -} - -async fn probe_service(name: &str, base_url: String, health_path: &str, configured: bool) -> Value { - if !configured { - return service_status(name, base_url, health_path, false, false, None, None); - } - let url = join_url(&base_url, health_path); - let client = match reqwest::Client::builder() - .timeout(Duration::from_millis(1_500)) - .redirect(reqwest::redirect::Policy::none()) - .build() - { - Ok(client) => client, - Err(error) => { - return service_status( - name, - base_url, - health_path, - true, - false, - None, - Some(error.to_string()), - ); - } - }; - match client.get(url).send().await { - Ok(response) => { - let status = response.status(); - service_status( - name, - base_url, - health_path, - true, - status.is_success(), - Some(status.as_u16()), - (!status.is_success()).then(|| format!("upstream_http_{}", status.as_u16())), - ) - } - Err(error) => service_status( - name, - base_url, - health_path, - true, - false, - None, - Some(error.to_string()), - ), - } -} - -async fn probe_weknora_service(base_url: String, configured: bool) -> Value { - if !configured { - return service_status( - "weknora", - base_url, - "/system/health", - false, - false, - None, - None, - ); - } - let client = match reqwest::Client::builder() - .timeout(Duration::from_millis(1_500)) - .redirect(reqwest::redirect::Policy::none()) - .build() - { - Ok(client) => client, - Err(error) => { - return service_status( - "weknora", - base_url, - "/system/health", - true, - false, - None, - Some(error.to_string()), - ); - } - }; - let system_health_url = join_url(&base_url, "/system/health"); - let mut system_request = client.get(system_health_url); - if let Some(api_key) = weknora_api_key() { - system_request = system_request.header("X-API-Key", api_key); - } - match system_request.send().await { - Ok(response) if response.status().is_success() => { - return service_status( - "weknora", - base_url, - "/system/health", - true, - true, - Some(response.status().as_u16()), - None, - ); - } - Ok(response) => { - let status = response.status(); - let public_base = base_url.trim_end_matches("/api/v1").to_string(); - let public_health_url = join_url(&public_base, "/health"); - match client.get(public_health_url).send().await { - Ok(public_response) => { - let public_status = public_response.status(); - let public_ok = public_status.is_success(); - service_status( - "weknora", - public_base, - "/health", - true, - public_ok, - Some(public_status.as_u16()), - (!public_ok).then(|| { - format!( - "system_health_http_{}; public_health_http_{}", - status.as_u16(), - public_status.as_u16() - ) - }), - ) - } - Err(error) => service_status( - "weknora", - base_url, - "/system/health", - true, - false, - Some(status.as_u16()), - Some(format!( - "system_health_http_{}; public_health_error={error}", - status.as_u16() - )), - ), - } - } - Err(error) => { - let public_base = base_url.trim_end_matches("/api/v1").to_string(); - let public_health_url = join_url(&public_base, "/health"); - match client.get(public_health_url).send().await { - Ok(public_response) => { - let public_status = public_response.status(); - let public_ok = public_status.is_success(); - service_status( - "weknora", - public_base, - "/health", - true, - public_ok, - Some(public_status.as_u16()), - (!public_ok).then(|| { - format!( - "system_health_error={error}; public_health_http_{}", - public_status.as_u16() - ) - }), - ) - } - Err(public_error) => service_status( - "weknora", - base_url, - "/system/health", - true, - false, - None, - Some(format!( - "system_health_error={error}; public_health_error={public_error}" - )), - ), - } - } - } -} - -fn mnote_user_id(state: &AppState, context: &RequestContext) -> String { - if let Some(raw_token) = context - .auth - .cookie_header - .as_deref() - .and_then(|cookies| raw_cookie_value(cookies, COOKIE_MNOTE_SESSION)) - { - let token_hash = session_token_hash(&raw_token); - if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { - return resolved.user.id; - } - } - let actor_id = context.auth.actor_id.trim(); - if !actor_id.is_empty() && actor_id != "anonymous" { - return actor_id.to_string(); - } - if let Some(authorization) = context.auth.authorization.as_deref() { - return stable_hash("auth", authorization); - } - if let Some(cookie) = context.auth.cookie_header.as_deref() { - return stable_hash("cookie", cookie); - } - "anonymous".to_string() -} - -fn build_allowed_roots(state: &AppState, user_id: &str) -> Vec { - state - .control_plane() - .list_directory_grants_for_actor(user_id) - .unwrap_or_default() - .into_iter() - .filter(|grant| grant.status.trim() == "active") - .filter(|grant| !grant.root_uri.trim().is_empty()) - .map(|grant| { - json!({ - "grantId": grant.id, - "workspaceId": grant.workspace_id, - "rootUri": grant.root_uri, - "rootPath": grant.root_path, - "permission": grant.permission, - "recursive": grant.recursive, - "capabilities": serde_json::from_str::>(&grant.capabilities_json).unwrap_or_default(), - "source": grant.source, - }) - }) - .collect() -} - -fn text_or_default(value: Option<&str>, fallback: &str) -> String { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(fallback) - .to_string() -} - -fn file_path_from_root_uri(root_uri: &str) -> Option { - let root_uri = root_uri.trim(); - let file_path = root_uri.strip_prefix("file://")?; - (!file_path.trim().is_empty()).then(|| PathBuf::from(file_path)) -} - -fn default_openhub_workspace_root_uri(user_id: &str) -> String { - format!("file:///mnt/Data1T/Mnote_data/users/{user_id}/workspaces/my-space") -} - -fn preferred_openhub_root_uri(root: &Value) -> Option { - root.get("rootUri") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| value.starts_with("file://")) - .map(ToOwned::to_owned) - .or_else(|| { - root.get("rootPath") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| format!("file://{value}")) - }) - .or_else(|| { - root.get("rootUri") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }) -} - -fn default_allowed_roots_for_scope(default_allowed_roots: &[Value], root_uri: &str) -> Value { - let root_uri = root_uri.trim(); - let matched_root = default_allowed_roots - .iter() - .find(|root| { - preferred_openhub_root_uri(root) - .as_deref() - .is_some_and(|candidate| candidate == root_uri) - }) - .cloned(); - - if let Some(root) = matched_root { - return json!([root]); - } - - json!([{ - "rootUri": root_uri, - "permission": "write", - "recursive": true, - "capabilities": ["ai", "read", "write"], - "source": "mnote_openhub_default_root", - }]) -} - -fn artifact_index_store_path(root_uri: Option<&str>) -> PathBuf { - if let Ok(path) = std::env::var("MNOTE_OPENHUB_ARTIFACT_INDEX_PATH") { - let path = path.trim(); - if !path.is_empty() { - return PathBuf::from(path); - } - } - if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) { - return root_path - .join(".mnote") - .join("page-ai-openhub-artifact-index.json"); - } - std::env::temp_dir() - .join("mnote-web") - .join("page-ai-openhub-artifact-index.json") -} - -fn load_artifact_index_records(path: &PathBuf) -> Result, WebError> { - let content = match fs::read_to_string(path) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => { - return Err(WebError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "page_ai_openhub_artifact_index_read_failed", - format!("读取 OpenHub artifact index 失败:{error}"), - )); - } - }; - let payload: Value = serde_json::from_str(&content).map_err(|error| { - WebError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "page_ai_openhub_artifact_index_parse_failed", - format!("解析 OpenHub artifact index 失败:{error}"), - ) - })?; - if let Some(records) = payload.get("records").and_then(Value::as_array) { - return Ok(records.clone()); - } - if let Some(records) = payload.as_array() { - return Ok(records.clone()); - } - Ok(Vec::new()) -} - -fn write_artifact_index_records(path: &PathBuf, records: &[Value]) -> Result<(), WebError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - WebError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "page_ai_openhub_artifact_index_dir_failed", - format!("创建 OpenHub artifact index 目录失败:{error}"), - ) - })?; - } - let payload = json!({ - "schema": "mnote.page_ai_openhub_artifact_index_store.v1", - "messageFulltextCopied": false, - "doesNotStore": [ - "openhub_message_fulltext", - "openhub_conversation_message_rows", - "assistant_text", - "user_prompt" - ], - "records": records, - }); - fs::write( - path, - format!( - "{}\n", - serde_json::to_string_pretty(&payload).map_err(|error| { - WebError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "page_ai_openhub_artifact_index_serialize_failed", - format!("序列化 OpenHub artifact index 失败:{error}"), - ) - })? - ), - ) - .map_err(|error| { - WebError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "page_ai_openhub_artifact_index_write_failed", - format!("写入 OpenHub artifact index 失败:{error}"), - ) - }) -} - -fn reject_fulltext_message_fields(payload: &Value) -> Result<(), WebError> { - let Some(object) = payload.as_object() else { - return Err(WebError::bad_request_code( - "page_ai_openhub_artifact_index_bad_request", - "OpenHub artifact index 请求必须是 JSON object", - )); - }; - let forbidden = [ - "message", - "messages", - "messageContent", - "conversation", - "conversationMessages", - "assistantMessage", - "userMessage", - "content", - "text", - "transcript", - ]; - if let Some(key) = forbidden.iter().find(|key| object.contains_key(**key)) { - return Err(WebError::new( - StatusCode::BAD_REQUEST, - "page_ai_openhub_artifact_index_forbidden_fulltext_field", - format!("OpenHub artifact index 只保存轻量 locator,不接收消息全文字段:{key}"), - )); - } - Ok(()) -} - -fn trim_optional(value: Option) -> Option { - value - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn normalize_artifact_record( - context: &RequestContext, - request: OpenHubArtifactIndexUpsertRequest, -) -> Result { - let openhub_session_id = request.openhub_session_id.trim().to_string(); - let kind = request.kind.trim().to_string(); - if openhub_session_id.is_empty() || kind.is_empty() { - return Err(WebError::bad_request_code( - "page_ai_openhub_artifact_index_bad_request", - "openhubSessionId 和 kind 不能为空", - )); - } - let provider_id = trim_optional(request.provider_id); - let path = trim_optional(request.path); - let root_uri = trim_optional(request.root_uri); - let workspace_id = trim_optional(request.workspace_id) - .or_else(|| context.workspace.workspace_id.clone()) - .unwrap_or_else(|| "default-workspace".to_string()); - let page_resource_id = trim_optional(request.page_resource_id); - let citation_payload = request.citation_payload.unwrap_or_else(|| json!({})); - let id_seed = format!( - "{}\n{}\n{}\n{}\n{}", - openhub_session_id, - kind, - provider_id.as_deref().unwrap_or(""), - path.as_deref().unwrap_or(""), - citation_payload - ); - Ok(json!({ - "schema": "mnote.page_ai_openhub_artifact_index_record.v1", - "id": stable_hash("openhub_artifact", &id_seed), - "openhubSessionId": openhub_session_id.clone(), - "openhub_session_id": openhub_session_id, - "kind": kind.clone(), - "providerId": provider_id.clone(), - "provider_id": provider_id, - "path": path.clone(), - "citationPayload": citation_payload.clone(), - "citation_payload": citation_payload, - "rootUri": root_uri.clone(), - "workspaceId": workspace_id, - "pageResourceId": page_resource_id, - "createdAtMs": now_ms(), - "messageFulltextCopied": false, - })) -} - -fn build_scope( - state: &AppState, - context: &RequestContext, - request: &OpenHubBootstrapRequest, -) -> Value { - let mnote_user_id = mnote_user_id(state, context); - let mnote_display_name = crate::routes::gateway::current_actor_display_name(state, context); - let default_allowed_roots = build_allowed_roots(state, &mnote_user_id); - let default_root_uri = default_allowed_roots - .iter() - .find_map(preferred_openhub_root_uri) - .unwrap_or_else(|| default_openhub_workspace_root_uri(&mnote_user_id)); - let workspace_id = text_or_default( - request - .workspace_id - .as_deref() - .or(context.workspace.workspace_id.as_deref()), - "default-workspace", - ); - let root_uri = text_or_default(request.root_uri.as_deref(), &default_root_uri); - let page_resource_id = text_or_default( - request - .page_id - .as_deref() - .or(request.page_path.as_deref()) - .or(request.page_title.as_deref()), - "current-page", - ); - let scope_seed = format!("{mnote_user_id}\n{workspace_id}\n{root_uri}\n{page_resource_id}"); - let openhub_user_key = stable_hash("openhub_user", &mnote_user_id); - let openhub_workspace_key = - stable_hash("openhub_workspace", &format!("{workspace_id}\n{root_uri}")); - let openhub_session_scope = stable_hash("openhub_session", &scope_seed); - let root_scope = json!({ - "workspaceId": workspace_id, - "rootUri": root_uri, - "pageResourceId": page_resource_id, - "pagePath": request.page_path.as_deref(), - "pageAbsolutePath": request.page_absolute_path.as_deref(), - "pageTitle": request.page_title.as_deref(), - }); - let allowed_roots = request - .allowed_roots - .as_ref() - .cloned() - .unwrap_or_else(|| default_allowed_roots_for_scope(&default_allowed_roots, &root_uri)); - let weknora_seed = format!( - "{openhub_user_key}\n{openhub_workspace_key}\n{}", - allowed_roots.to_string() - ); - let weknora_tool_scope = stable_hash("weknora_tool", &weknora_seed); - json!({ - "schema": "mnote.page_ai_openhub_scope.v1", - "authTruth": "mnote_session", - "rejectsFrontendAuthTruth": ["openhub_jwt", "openhub_localStorage", "openhub_login_page"], - "mnoteUserId": mnote_user_id, - "mnoteActorId": context.auth.actor_id, - "mnoteDisplayName": mnote_display_name, - "openhubUserKey": openhub_user_key, - "openhub_user_key": openhub_user_key, - "openhubWorkspaceKey": openhub_workspace_key, - "workspace_key": openhub_workspace_key, - "openhubSessionScope": openhub_session_scope, - "sessionScope": openhub_session_scope, - "session_scope": openhub_session_scope, - "skillScope": stable_hash("openhub_skill", &format!("{openhub_user_key}\n{openhub_workspace_key}")), - "skill_scope": stable_hash("openhub_skill", &format!("{openhub_user_key}\n{openhub_workspace_key}")), - "mcpScope": stable_hash("openhub_mcp", &format!("{openhub_user_key}\n{openhub_workspace_key}")), - "toolPermissionScope": stable_hash("openhub_tool_permission", &scope_seed), - "tool_permission_scope": stable_hash("openhub_tool_permission", &scope_seed), - "weknoraToolScope": weknora_tool_scope, - "weknora_tool_scope": weknora_tool_scope, - "workspaceScope": root_scope, - "allowedRoots": allowed_roots, - "selectionPresent": request.selection.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()), - }) -} - -fn header_string(value: Option<&Value>) -> Option { - value - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn request_scope_from_query(original_uri: &Uri) -> Option { - original_uri.query().and_then(|query| { - query.split('&').find_map(|part| { - let (key, value) = part.split_once('=')?; - (key == "scope" && !value.trim().is_empty()).then(|| value.to_string()) - }) - }) -} - -fn mnote_scope_from_query(original_uri: &Uri) -> Option { - let encoded = original_uri.query().and_then(|query| { - query.split('&').find_map(|part| { - let (key, value) = part.split_once('=')?; - (key == "mnoteScope" && !value.trim().is_empty()).then(|| value.to_string()) - }) - })?; - let bytes = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?; - serde_json::from_slice::(&bytes).ok() -} - -fn proxy_query_without_internal_scope(original_uri: &Uri) -> Option { - let parts = original_uri - .query()? - .split('&') - .filter(|part| { - let key = part.split_once('=').map(|(key, _)| key).unwrap_or(*part); - key != "mnoteScope" && key != "scope" - }) - .filter(|part| !part.trim().is_empty()) - .map(ToOwned::to_owned) - .collect::>(); - (!parts.is_empty()).then(|| parts.join("&")) -} - -fn add_mnote_scope_headers( - mut request: reqwest::RequestBuilder, - context: &RequestContext, - scope: &Value, - original_uri: &Uri, -) -> reqwest::RequestBuilder { - let workspace = scope - .get("workspaceScope") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - request = request - .header(HeaderName::from_static("x-mnote-openhub-host"), "page-ai") - .header( - HeaderName::from_static("x-mnote-auth-truth"), - "mnote_session", - ) - .header( - HeaderName::from_static("x-mnote-actor-id"), - context.auth.actor_id.clone(), - ); - if let Some(value) = header_string(scope.get("openhub_user_key")) { - request = request.header(HeaderName::from_static("x-mnote-user-key"), value); - } - if let Some(value) = header_string(scope.get("mnoteUserId")) { - request = request.header(HeaderName::from_static("x-mnote-user-id"), value); - } - if let Some(value) = header_string(scope.get("mnoteDisplayName")) { - request = request.header(HeaderName::from_static("x-mnote-display-name"), value); - } - if let Some(value) = header_string(scope.get("workspace_key")) { - request = request.header(HeaderName::from_static("x-mnote-workspace-key"), value); - } - if let Some(value) = - header_string(scope.get("session_scope")).or_else(|| request_scope_from_query(original_uri)) - { - request = request.header(HeaderName::from_static("x-mnote-session-scope"), value); - } - if let Some(value) = header_string(scope.get("skill_scope")) { - request = request.header(HeaderName::from_static("x-mnote-skill-scope"), value); - } - if let Some(value) = header_string(scope.get("tool_permission_scope")) { - request = request.header( - HeaderName::from_static("x-mnote-tool-permission-scope"), - value, - ); - } - if let Some(allowed_roots) = scope.get("allowedRoots") { - request = request.header( - HeaderName::from_static("x-mnote-allowed-roots"), - allowed_roots.to_string(), - ); - } - if let Some(value) = header_string(scope.get("weknora_tool_scope")) { - request = request.header(HeaderName::from_static("x-mnote-weknora-tool-scope"), value); - } - if let Some(value) = context - .auth - .cookie_header - .as_deref() - .and_then(|cookies| raw_cookie_value(cookies, COOKIE_MNOTE_SESSION)) - .map(|value| format!("{COOKIE_MNOTE_SESSION}={value}")) - { - request = request.header(HeaderName::from_static("x-mnote-session-cookie"), value); - } - if let Some(value) = workspace.get("workspaceId").and_then(Value::as_str) { - request = request.header(HeaderName::from_static("x-mnote-workspace-id"), value); - } - if let Some(value) = workspace.get("rootUri").and_then(Value::as_str) { - request = request.header(HeaderName::from_static("x-mnote-root-uri"), value); - } - if let Some(value) = workspace.get("pageResourceId").and_then(Value::as_str) { - request = request.header(HeaderName::from_static("x-mnote-page-resource-id"), value); - } - request -} - -pub async fn status( - State(_state): State, - Extension(context): Extension, -) -> Result, WebError> { - ensure_authenticated(&context)?; - let openhub_base = openhub_base_url(); - let opencode_base = opencode_base_url(); - let weknora_base = weknora_endpoint(); - let openhub_configured = std::env::var("MNOTE_OPENHUB_BASE_URL") - .ok() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); - let opencode_configured = std::env::var("MNOTE_OPENCODE_BASE_URL") - .ok() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); - let weknora_configured = std::env::var("MNOTE_WEKNORA_ENDPOINT") - .or_else(|_| std::env::var("WEKNORA_ENDPOINT")) - .ok() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); - let openhub_fastapi = probe_service( - "openhub_fastapi", - openhub_base.clone(), - "/api/health", - openhub_configured, - ) - .await; - let opencode = probe_service( - "opencode", - opencode_base, - "/global/health", - opencode_configured, - ) - .await; - let weknora = probe_weknora_service(weknora_base, weknora_configured).await; - let mnote_binding = json!({ - "name": "mnote_binding", - "status": "ready", - "configured": true, - "reachable": true, - "authTruth": "mnote_session", - "bindingTruth": "mnote_control_plane_scope_binding", - "messageTruth": "openhub_session_message_when_upstream_ready", - }); - let layers = json!({ - "openhub_fastapi": openhub_fastapi, - "opencode": opencode, - "weknora": weknora, - "mnote_binding": mnote_binding, - }); - let degraded = layers - .as_object() - .map(|items| { - items.values().any(|item| { - item.get("status") - .and_then(Value::as_str) - .is_some_and(|status| status != "ready") - }) - }) - .unwrap_or(true); - Ok(Json(json!({ - "ok": !degraded, - "schema": "mnote.page_ai_openhub_status.v1", - "status": if degraded { "degraded" } else { "ready" }, - "mode": "host_boundary_with_real_health_probe", - "baseUrl": openhub_base_url(), - "aiPanelPath": OPENHUB_AI_ENTRY_PATH, - "bootstrapApi": "/api/page-ai/openhub/bootstrap", - "proxyBoundary": { - "allowed": ["/page-ai/openhub/ai", "/page-ai/openhub/ai/{*path}"], - "blocked": ["login", "admin", "file", "files", "knowledge", "git"], - "rule": "MNote 只承载 OpenHub AI 面板路径,非 AI 产品入口不接管 MNote" - }, - "layers": layers, - "openhub_fastapi": layers["openhub_fastapi"], - "opencode": layers["opencode"], - "weknora": layers["weknora"], - "mnote_binding": layers["mnote_binding"], - "fallback": { - "enabled": false, - "reason": if degraded { "OpenHub/opencode/WeKnora 外部链路未全部 ready,前端应提示用户刷新或检查服务" } else { "" } - }, - "guardedRoutes": [ - "/page-ai/openhub/login", - "/page-ai/openhub/admin", - "/page-ai/openhub/file", - "/page-ai/openhub/files", - "/page-ai/openhub/knowledge", - "/page-ai/openhub/git" - ], - "authBoundary": { - "truth": "mnote_session", - "rejectedTruth": ["OpenHub JWT", "OpenHub localStorage"], - "openhubUserKey": "derived_from_mnote_user_id", - "workspaceScope": "derived_from_workspace_id_root_uri_allowed_roots" - } - }))) -} - -pub async fn bootstrap( - State(state): State, - Extension(context): Extension, - Json(request): Json, -) -> Result, WebError> { - ensure_authenticated(&context)?; - let scope = build_scope(&state, &context, &request); - let session_scope = scope - .get("openhubSessionScope") - .and_then(Value::as_str) - .unwrap_or("openhub_session_unknown"); - let encoded_scope = URL_SAFE_NO_PAD.encode(session_scope.as_bytes()); - let encoded_mnote_scope = URL_SAFE_NO_PAD.encode(scope.to_string().as_bytes()); - Ok(Json(json!({ - "ok": true, - "schema": "mnote.page_ai_openhub_bootstrap.v1", - "bootstrapKind": "mnote_owned_openhub_host_boundary", - "authTruth": "mnote_session", - "openhubJwtAccepted": false, - "openhubLocalStorageAccepted": false, - "openhubBaseUrl": openhub_base_url(), - "openhubIframeUrl": format!("/page-ai/openhub/ai?scope={encoded_scope}&mnoteScope={encoded_mnote_scope}"), - "scope": scope, - "openhub_user_key": scope["openhub_user_key"], - "workspace_key": scope["workspace_key"], - "session_scope": scope["session_scope"], - "skill_scope": scope["skill_scope"], - "tool_permission_scope": scope["tool_permission_scope"], - "weknora_tool_scope": scope["weknora_tool_scope"], - "fallback": { - "enabled": false, - "reason": "OpenHub FastAPI/proxy 不可用时提示用户刷新或检查服务,不再回退到 opencode legacy host" - }, - "nonAiRouteGuard": { - "login": "blocked_by_mnote_auth_truth", - "admin": "blocked_by_static_guard", - "file": "blocked_use_mnote_document_pane", - "knowledge": "blocked_use_mnote_weknora_boundary", - "git": "blocked_git_snapshot_restore_boundary" - } - }))) -} - -pub async fn artifact_index_get( - Extension(context): Extension, - axum::extract::Query(query): axum::extract::Query, -) -> Result, WebError> { - ensure_authenticated(&context)?; - let root_uri = query.root_uri.as_deref(); - let store_path = artifact_index_store_path(root_uri); - let mut records = load_artifact_index_records(&store_path)?; - if let Some(session_id) = query - .openhub_session_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - records.retain(|record| { - record - .get("openhubSessionId") - .or_else(|| record.get("openhub_session_id")) - .and_then(Value::as_str) - == Some(session_id) - }); - } - Ok(Json(json!({ - "ok": true, - "schema": "mnote.page_ai_openhub_artifact_index.v1", - "storage": { - "kind": "mnote_lightweight_file_index", - "path": store_path, - "messageFulltextCopied": false, - "doesNotStore": [ - "openhub_message_fulltext", - "openhub_conversation_message_rows", - "assistant_text", - "user_prompt" - ] - }, - "records": records, - }))) -} - -pub async fn artifact_index_upsert( - Extension(context): Extension, - Json(payload): Json, -) -> Result, WebError> { - ensure_authenticated(&context)?; - reject_fulltext_message_fields(&payload)?; - let request: OpenHubArtifactIndexUpsertRequest = - serde_json::from_value(payload).map_err(|error| { - WebError::new( - StatusCode::BAD_REQUEST, - "page_ai_openhub_artifact_index_bad_request", - format!("OpenHub artifact index 请求格式错误:{error}"), - ) - })?; - let root_uri = request - .root_uri - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - let store_path = artifact_index_store_path(root_uri); - let record = normalize_artifact_record(&context, request)?; - let record_id = record - .get("id") - .and_then(Value::as_str) - .unwrap_or("openhub_artifact_unknown") - .to_string(); - let mut records = load_artifact_index_records(&store_path)?; - records.retain(|item| item.get("id").and_then(Value::as_str) != Some(record_id.as_str())); - records.push(record.clone()); - write_artifact_index_records(&store_path, &records)?; - Ok(Json(json!({ - "ok": true, - "schema": "mnote.page_ai_openhub_artifact_index_upsert.v1", - "messageFulltextCopied": false, - "storage": { - "kind": "mnote_lightweight_file_index", - "path": store_path - }, - "record": record, - "recordsCount": records.len(), - }))) -} - -fn static_ai_shell_html() -> String { - r#" - - - - - MNote OpenHub AI Host - - - -
-
-

OpenHub AI 面板边界已由 MNote 承载

-

当前页只作为最小 host/bootstrap 占位,不读取 OpenHub JWT 或 localStorage 登录态。

-

真实 OpenHub FastAPI/React AI 面板 proxy 接入后,应继续使用 /api/page-ai/openhub/bootstrap 注入 MNote scope。

-

非 AI 路由 login/admin/file/knowledge 已由 MNote 静态 guard 接管。

-
-
- -"# - .to_string() -} - -fn rewrite_openhub_spa_html(html: &str) -> String { - html.replace("src=\"/assets/", "src=\"/page-ai/openhub/ai/assets/") - .replace("href=\"/assets/", "href=\"/page-ai/openhub/ai/assets/") -} - -fn html_response(body: String) -> Response { - Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .body(Body::from(body)) - .unwrap_or_else(|_| Response::new(Body::from(static_ai_shell_html()))) -} - -pub async fn ai_shell( - State(state): State, - Extension(context): Extension, - OriginalUri(original_uri): OriginalUri, -) -> Result { - ensure_authenticated(&context)?; - let client = reqwest::Client::builder() - .timeout(Duration::from_millis(2_500)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| { - WebError::new( - StatusCode::BAD_GATEWAY, - "page_ai_openhub_shell_client_error", - format!("OpenHub AI shell client 初始化失败:{error}"), - ) - .with_context(&context) - })?; - let scope = mnote_scope_from_query(&original_uri).unwrap_or_else(|| { - let scope_request = OpenHubBootstrapRequest { - page_id: None, - workspace_id: context.workspace.workspace_id.clone(), - page_title: None, - page_path: None, - page_absolute_path: None, - root_uri: None, - selection: None, - allowed_roots: None, - }; - build_scope(&state, &context, &scope_request) - }); - let request = add_mnote_scope_headers( - client.get(join_url(&openhub_base_url(), "/")), - &context, - &scope, - &original_uri, - ); - match request.send().await { - Ok(response) if response.status().is_success() => { - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_ascii_lowercase(); - if content_type.contains("text/html") { - match response.text().await { - Ok(html) => Ok(html_response(rewrite_openhub_spa_html(&html))), - Err(_) => Ok(html_response(static_ai_shell_html())), - } - } else { - Ok(html_response(static_ai_shell_html())) - } - } - _ => Ok(html_response(static_ai_shell_html())), - } -} - -fn strip_hop_headers(headers: &HeaderMap) -> HeaderMap { - let mut output = HeaderMap::new(); - for (name, value) in headers { - let lower = name.as_str().to_ascii_lowercase(); - if matches!( - lower.as_str(), - "host" - | "connection" - | "content-length" - | "transfer-encoding" - | "upgrade" - | "proxy-authenticate" - | "proxy-authorization" - ) { - continue; - } - output.insert(name.clone(), value.clone()); - } - output -} - -fn openhub_ai_upstream_path(path: Option<&str>) -> String { - let clean_path = path.unwrap_or("").trim_start_matches('/'); - if clean_path.is_empty() { - "/".to_string() - } else { - format!("/{clean_path}") - } -} - -fn response_with_status(status: StatusCode, body: String) -> Response { - Response::builder() - .status(status) - .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(Body::from(body)) - .unwrap_or_else(|_| Response::new(Body::from("openhub proxy response build failed"))) -} - -fn is_streaming_response(headers: &HeaderMap) -> bool { - let content_type = headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_ascii_lowercase(); - content_type.contains("text/event-stream") - || content_type.contains("application/x-ndjson") - || headers - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_ascii_lowercase() - .contains("no-cache") -} - -pub async fn ai_proxy( - State(state): State, - Extension(context): Extension, - OriginalUri(original_uri): OriginalUri, - Path(path): Path, - method: Method, - headers: HeaderMap, - body: Bytes, -) -> Result { - ensure_authenticated(&context)?; - let base_url = openhub_base_url(); - let query = proxy_query_without_internal_scope(&original_uri); - let upstream_path = openhub_ai_upstream_path(Some(&path)); - let mut upstream_url = join_url(&base_url, &upstream_path); - if let Some(query) = query.filter(|value| !value.is_empty()) { - upstream_url.push('?'); - upstream_url.push_str(&query); - } - let upstream_method = - reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET); - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| { - WebError::new( - StatusCode::BAD_GATEWAY, - "page_ai_openhub_proxy_client_error", - format!("OpenHub AI proxy client 初始化失败:{error}"), - ) - .with_context(&context) - })?; - let mut request = client.request(upstream_method, upstream_url); - let request_headers = strip_hop_headers(&headers); - for (name, value) in request_headers.iter() { - request = request.header(name, value); - } - let scope = mnote_scope_from_query(&original_uri).unwrap_or_else(|| { - let scope_request = OpenHubBootstrapRequest { - page_id: None, - workspace_id: context.workspace.workspace_id.clone(), - page_title: None, - page_path: None, - page_absolute_path: None, - root_uri: None, - selection: None, - allowed_roots: None, - }; - build_scope(&state, &context, &scope_request) - }); - request = add_mnote_scope_headers(request, &context, &scope, &original_uri); - let upstream = request.body(body).send().await.map_err(|error| { - WebError::new( - StatusCode::BAD_GATEWAY, - "page_ai_openhub_proxy_unreachable", - format!("OpenHub AI proxy 不可达:{error}"), - ) - .with_context(&context) - .with_details(json!({ - "fallback": { - "enabled": false, - "reason": "OpenHub AI proxy 不可达,不再回退到 opencode legacy host" - } - })) - })?; - let status = upstream.status(); - let headers = upstream.headers().clone(); - if is_streaming_response(&headers) { - let mut builder = Response::builder().status(status); - let response_headers = strip_hop_headers(&headers); - for (name, value) in response_headers.iter() { - builder = builder.header(name, value); - } - builder = builder - .header(header::CACHE_CONTROL, "no-cache, no-transform") - .header("x-accel-buffering", "no"); - let stream = upstream.bytes_stream().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("OpenHub AI proxy 流式响应读取失败:{error}"), - ) - }); - return Ok(builder.body(Body::from_stream(stream)).unwrap_or_else(|_| { - response_with_status( - StatusCode::BAD_GATEWAY, - "OpenHub AI proxy 流式响应构造失败".to_string(), - ) - })); - } - let content_type = headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_ascii_lowercase(); - let bytes = upstream.bytes().await.map_err(|error| { - WebError::new( - StatusCode::BAD_GATEWAY, - "page_ai_openhub_proxy_body_error", - format!("读取 OpenHub AI proxy 响应失败:{error}"), - ) - .with_context(&context) - })?; - let mut builder = Response::builder().status(status); - let response_headers = strip_hop_headers(&headers); - for (name, value) in response_headers.iter() { - builder = builder.header(name, value); - } - let body = if content_type.contains("text/html") { - String::from_utf8(bytes.to_vec()) - .map(|html| Body::from(rewrite_openhub_spa_html(&html))) - .unwrap_or_else(|_| Body::from(bytes)) - } else { - Body::from(bytes) - }; - Ok(builder.body(body).unwrap_or_else(|_| { - response_with_status( - StatusCode::BAD_GATEWAY, - "OpenHub AI proxy 响应构造失败".to_string(), - ) - })) -} - -pub async fn non_ai_route_guard( - Extension(context): Extension, -) -> Result, WebError> { - ensure_authenticated(&context)?; - Err(WebError::new( - StatusCode::FORBIDDEN, - "page_ai_openhub_non_ai_route_guarded", - "OpenHub 非 AI 路由由 MNote 接管:login/admin/file/knowledge/git 不允许作为 Page AI 登录、文件、知识库或 Git 真相", - ) - .with_context(&context) - .with_details(json!({ - "schema": "mnote.page_ai_openhub_non_ai_route_guard.v1", - "authTruth": "mnote_session", - "blocked": ["login", "admin", "file", "files", "knowledge", "git"], - "replacement": { - "login": "MNote SQLite control-plane auth", - "file": "MNote document pane/resource tree", - "knowledge": "MNote WeKnora provider boundary", - "admin": "MNote control-plane", - "git": "MNote local-first watcher/buffer/version boundary" - }, - "fallback": { - "enabled": false, - "reason": "OpenHub 非 AI 路由由 MNote 接管,不再回退到 opencode legacy host" - } - }))) -} diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task-page-ai-filechange-current-document-refresh-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task-page-ai-filechange-current-document-refresh-smoke.js deleted file mode 100644 index 656d86ba..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task-page-ai-filechange-current-document-refresh-smoke.js +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -require("./task779-openhub-file-edit-document-pane-refresh-smoke.js"); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task768-page-ai-openhub-host-static-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task768-page-ai-openhub-host-static-smoke.js deleted file mode 100644 index f8d7c967..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task768-page-ai-openhub-host-static-smoke.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); - -const repoRoot = path.resolve(__dirname, '..'); -const runtimePath = path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js'); -const routePath = path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_openhub.rs'); -const routesModPath = path.join(repoRoot, 'rust/crates/mnote-web/src/routes/mod.rs'); - -const runtime = fs.readFileSync(runtimePath, 'utf8'); -const route = fs.readFileSync(routePath, 'utf8'); -const routesMod = fs.readFileSync(routesModPath, 'utf8'); - -const checks = [ - ['openhub host enabled by default', runtime.includes('function pageAiOpenHubHostEnabled()') && runtime.includes('return true;') && runtime.includes('data-page-ai-openhub-host')], - ['openhub bootstrap api', runtime.includes('/api/page-ai/openhub/bootstrap') && routesMod.includes('/api/page-ai/openhub/bootstrap')], - ['mnote auth truth copy', route.includes('"authTruth": "mnote_session"') && runtime.includes('拒绝 OpenHub JWT/localStorage')], - ['openhub user key derived', route.includes('"openhubUserKey"') && route.includes('stable_hash("openhub_user"')], - ['workspace session tool scope', route.includes('"openhubSessionScope"') && route.includes('"skillScope"') && route.includes('"mcpScope"') && route.includes('"toolPermissionScope"')], - ['proxy injects mnote scope headers', route.includes('add_mnote_scope_headers') && route.includes('x-mnote-user-key') && route.includes('x-mnote-user-id') && route.includes('x-mnote-display-name') && route.includes('x-mnote-workspace-key') && route.includes('x-mnote-session-scope') && route.includes('x-mnote-tool-permission-scope') && route.includes('x-mnote-weknora-tool-scope')], - ['proxy carries full mnote scope internally', route.includes('mnoteScope') && route.includes('mnote_scope_from_query') && route.includes('proxy_query_without_internal_scope')], - ['snake case bootstrap scope aliases', route.includes('"openhub_user_key"') && route.includes('"workspace_key"') && route.includes('"session_scope"') && route.includes('"tool_permission_scope"')], - ['weknora tool scope', route.includes('"weknoraToolScope"') && route.includes('"weknora_tool_scope"') && runtime.includes('weknora_tool_scope')], - ['layered status endpoint', route.includes('"openhub_fastapi"') && route.includes('"opencode"') && route.includes('"weknora"') && route.includes('"mnote_binding"')], - ['degraded external services are explicit', route.includes('"degraded"') && route.includes('reachable') && route.includes('upstream_http_')], - ['ai proxy boundary', route.includes('pub async fn ai_proxy') && routesMod.includes('/page-ai/openhub/ai/{*path}')], - ['no visible opencode fallback action', !runtime.includes('openhub-use-opencode-fallback')], - ['non ai route guard', route.includes('page_ai_openhub_non_ai_route_guarded') && routesMod.includes('/page-ai/openhub/knowledge/{*path}') && routesMod.includes('/page-ai/openhub/file/{*path}') && routesMod.includes('/page-ai/openhub/git/{*path}')], - ['static ai shell', route.includes('data-mnote-openhub-ai-shell="static-boundary"') && routesMod.includes('/page-ai/openhub/ai')], - ['openhub quick address actions are native openhub source not proxy overlay', !route.includes('data-mnote-openhub-ai-quick-actions') && !route.includes('data-mnote-openhub-send-current-tab') && !route.includes('data-mnote-openhub-send-current-folder')], - ['openhub quick address bridge uses active tab only', runtime.includes("message.source === 'openhub-ai'") && runtime.includes("message.type === 'mnote:get-active-tab-address'") && runtime.includes('pageAiCurrentActiveTabEditorTarget') && runtime.includes("type: 'mnote:active-tab-address'")], - ['openhub folder action sends folder address only', runtime.includes('pageAiCurrentActiveTabAddressPayload') && runtime.includes('folderUrl') && runtime.includes("kind === 'folder' ? addressPayload.folderUrl : addressPayload.tabUrl")], - ['openhub diagnostics are hidden from user chrome', runtime.includes('wolai-page-ai-openhub-diagnostics') && runtime.includes('data-page-ai-openhub-bootstrap-copy hidden aria-hidden="true"')], - ['no duplicate mnote openhub shell header', !runtime.includes('

OpenHub AI

') && !runtime.includes('data-page-ai-openhub-runtime-status>OpenHub host boundary 静态占位')], - ['no visible openhub legacy fallback switch', !runtime.includes("localStorage.setItem('mnote.page_ai.openhub_host', '0')")], - ['openhub fallback payload has no legacy route', route.includes('"enabled": false') && !route.includes('"legacyRoute": "/page-ai/opencode"')], -]; - -const failed = checks.filter(([, ok]) => !ok); -if (failed.length) { - console.error('Page AI OpenHub host static smoke failed:'); - for (const [name] of failed) console.error(`- ${name}`); - process.exit(1); -} - -console.log('Page AI OpenHub host static smoke passed.'); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js deleted file mode 100644 index f216363b..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task770-openhub-git-snapshot-restore-guard-static-smoke.js +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); - -const openHubRoot = - process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub'; -const backendRoot = path.join(openHubRoot, 'smart-query-backend'); -const disableEnv = 'MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE'; -const legacyDisableEnv = 'OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE'; - -function read(relativePath) { - return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8'); -} - -function assertCheck(name, passed) { - if (!passed) failures.push(name); -} - -function includesAll(source, needles) { - return needles.every((needle) => source.includes(needle)); -} - -const failures = []; - -const gitSnapshot = read('app/services/git_snapshot.py'); -const stream = read('app/services/stream.py'); -const taskExecutor = read('app/services/task_executor.py'); -const session = read('app/api/session.py'); - -assertCheck( - 'git_snapshot exposes MNote disable env and legacy equivalent', - includesAll(gitSnapshot, [disableEnv, legacyDisableEnv, 'def is_snapshot_restore_disabled']) -); - -assertCheck( - 'git_snapshot low-level git write command guard covers destructive/write commands', - includesAll(gitSnapshot, [ - '_GIT_WRITE_COMMANDS', - '"init"', - '"config"', - '"add"', - '"commit"', - '"checkout"', - '"restore"', - '"reset"', - '"revert"', - 'is_snapshot_restore_disabled() and _is_git_write(args)', - ]) -); - -assertCheck( - 'git_snapshot high-level write APIs short-circuit when disabled', - includesAll(gitSnapshot, [ - 'def init_git_repo', - 'def create_snapshot', - 'def create_restore_snapshot', - 'def restore_all', - 'def restore_single_file', - 'disabled by {GIT_SNAPSHOT_RESTORE_DISABLE_ENV}', - ]) -); - -assertCheck( - 'stream automatic snapshot path is guarded before init/create_snapshot', - stream.includes('not git_snap.is_snapshot_restore_disabled()') && - stream.includes('git_snap.init_git_repo') && - stream.includes('git_snap.create_snapshot') && - stream.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE') -); - -assertCheck( - 'task_executor automatic snapshot path is guarded before init/create_snapshot', - taskExecutor.includes('not git_snapshot.is_snapshot_restore_disabled()') && - taskExecutor.includes('git_snapshot.init_git_repo') && - taskExecutor.includes('git_snapshot.create_snapshot') && - taskExecutor.includes('Git snapshot skipped: disabled by MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE') -); - -const restoreRouteGuardCount = ( - session.match(/git_snapshot\.is_snapshot_restore_disabled\(\)/g) || [] -).length; -assertCheck( - 'session restore routes reject when disabled', - restoreRouteGuardCount >= 2 && - session.includes('Git snapshot/restore 写链已由 MNote 禁用') && - session.includes('git_snapshot.restore_all') && - session.includes('git_snapshot.restore_single_file') && - session.includes('git_snapshot.create_restore_snapshot') -); - -if (failures.length) { - console.error('OpenHub git snapshot/restore guard static smoke failed:'); - for (const failure of failures) console.error(`- ${failure}`); - process.exit(1); -} - -console.log('OpenHub git snapshot/restore guard static smoke passed.'); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task771-openhub-mnote-scope-static-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task771-openhub-mnote-scope-static-smoke.js deleted file mode 100644 index 779f3a4e..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task771-openhub-mnote-scope-static-smoke.js +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env node - -const fs = require('fs'); -const path = require('path'); - -const openHubRoot = - process.env.OPENHUB_RESEARCH_ROOT || '/mnt/Data1T/Mnote_data/openhub/OpenHub'; -const backendRoot = path.join(openHubRoot, 'smart-query-backend'); - -function read(relativePath) { - return fs.readFileSync(path.join(backendRoot, relativePath), 'utf8'); -} - -function assertCheck(name, passed) { - if (!passed) failures.push(name); -} - -function includesAll(source, needles) { - return needles.every((needle) => source.includes(needle)); -} - -const failures = []; - -const mnoteScope = read('app/core/mnote_scope.py'); -const auth = read('app/core/auth.py'); -const query = read('app/api/query.py'); -const session = read('app/api/session.py'); -const stream = read('app/services/stream.py'); - -const requiredHeaders = [ - 'X-MNote-User-Key', - 'X-MNote-Workspace-Key', - 'X-MNote-Session-Scope', - 'X-MNote-Root-Uri', - 'X-MNote-Page-Resource-Id', - 'X-MNote-Tool-Permission-Scope', - 'X-MNote-WeKnora-Tool-Scope', -]; - -assertCheck( - 'mnote scope helper exists with all controlled headers', - includesAll(mnoteScope, [ - 'MNOTE_HOST_TRUTH = "mnote_controlled_headers"', - 'def derive_mnote_user', - 'def resolve_user_workspace', - 'def resolve_user_asset_workspace', - 'def get_mnote_scope_metadata', - 'X-MNote-Display-Name', - ...requiredHeaders, - ]) -); - -assertCheck( - 'derived user is stable per MNote user and not tied to workspace scope', - includesAll(mnoteScope, [ - 'def _stable_openhub_user_id(user_key: str)', - 'mnote_openhub_user_id', - 'user_key', - 'workspace_key', - 'display_name', - 'get_user_by_username', - '"openhub_user_id"', - '"openhub_username"', - ]) && - !mnoteScope.includes('def _stable_openhub_user_id(user_key: str, workspace_key: str)') && - !mnoteScope.includes('_stable_openhub_user_id(user_key, workspace_key)') && - !mnoteScope.includes('mnote_shared_user') -); - -assertCheck( - 'session and workspace scope are derived from MNote scope', - includesAll(mnoteScope, [ - 'def _stable_openhub_session_id', - 'mnote_openhub_session', - 'openhub_workspace_scope', - 'session_scope', - 'root_uri', - 'workspace_path', - '_root_uri_to_workspace_path', - ]) -); - -assertCheck( - 'tool and weknora scopes are retained as MNote scope metadata', - includesAll(mnoteScope, [ - 'tool_permission_scope', - 'weknora_tool_scope', - 'provisioned_workspace_path', - '_parse_scope_header', - '"mnote_scope"', - '"source_headers"', - ]) -); - -assertCheck( - 'MNote host mode rejects frontend JWT/localStorage truth', - includesAll(mnoteScope, [ - 'REJECTED_MNOTE_HOST_TRUTHS', - 'localStorage', - 'OpenHub JWT', - 'frontend JWT', - ]) && - auth.includes('derive_mnote_user(request)') && - auth.includes('HTTPBearer(auto_error=False)') && - auth.indexOf('derive_mnote_user(request)') < auth.indexOf('validate_token(token)') -); - -assertCheck( - 'query/session entries consume derived workspace and scope', - query.includes('resolve_user_workspace(current_user)') && - query.includes('not current_user.get("mnote_host_mode")') && - query.includes('mnote_scope = get_mnote_scope_metadata(current_user)') && - session.includes('resolve_user_workspace(current_user)') && - session.includes('mnote_scope=get_mnote_scope_metadata(current_user)') -); - -assertCheck( - 'stream persists MNote scope metadata with user message', - includesAll(stream, [ - 'mnote_scope: Optional[dict] = None', - 'metadata["mnote_scope"] = mnote_scope', - 'database.save_session', - 'user_id', - 'workspace_path', - 'not mnote_scope', - ]) -); - -if (failures.length) { - console.error('OpenHub MNote scope bridge static smoke failed:'); - for (const failure of failures) console.error(`- ${failure}`); - process.exit(1); -} - -console.log('OpenHub MNote scope bridge static smoke passed.'); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task773-page-ai-openhub-browser-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task773-page-ai-openhub-browser-smoke.js deleted file mode 100644 index b3df9591..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task773-page-ai-openhub-browser-smoke.js +++ /dev/null @@ -1,553 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - ensureAuthenticated, - getViewerIdentity, -} = require("./tree-shell-smoke-helpers"); - -const TASK = "task773-page-ai-openhub-browser-smoke"; -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime"); -const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser.png"); -const RESULT_PATH = path.join(OUTPUT_DIR, "page-ai-openhub-browser-result.json"); -const TEST_EMAIL = "mnote.e2e@example.com"; -const TEST_PASSWORD = "MnoteE2E123!"; -const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH - || ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"] - .find((candidate) => fs.existsSync(candidate)); - -class SmokeFailure extends Error { - constructor(kind, message, details = {}) { - super(message); - this.name = "SmokeFailure"; - this.kind = kind; - this.details = details; - } -} - -function visibleSelectorScript(selectors) { - return selectors.some((selector) => { - const nodes = Array.from(document.querySelectorAll(selector)); - return nodes.some((node) => { - if (!(node instanceof HTMLElement)) return false; - const style = window.getComputedStyle(node); - const rect = node.getBoundingClientRect(); - return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; - }); - }); -} - -async function assertServiceReachable(baseUrl) { - let response; - try { - response = await fetch(`${baseUrl}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) }); - } catch (error) { - throw new SmokeFailure("service_unreachable", `MNote 3000 服务不可达:${error.message}`, { baseUrl }); - } - if (!response.ok && response.status !== 303) { - throw new SmokeFailure("service_unreachable", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl }); - } -} - -async function loginWithUiFirst(page, requestContext) { - await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); - if (!page.url().includes("/auth")) { - return getViewerIdentity(requestContext); - } - - const quickLogin = page.getByRole("button", { name: "测试账号快速登录" }); - if (await quickLogin.isVisible({ timeout: 8_000 }).catch(() => false)) { - await quickLogin.click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined); - } - - if (page.url().includes("/auth")) { - const account = page.locator('input[name="account"], input[type="email"], input[data-auth-field="account"]').first(); - const password = page.locator('input[name="password"], input[type="password"]').first(); - const submit = page.getByRole("button", { name: /^登录$|账号登录|登录$/ }).first(); - if (!(await account.isVisible({ timeout: 2_000 }).catch(() => false)) || !(await password.isVisible({ timeout: 2_000 }).catch(() => false))) { - throw new SmokeFailure("auth_failed", "认证页未出现快速登录,也找不到账号密码输入框", { url: page.url() }); - } - await account.fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS }); - await password.fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS }); - await submit.click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined); - } - - if (page.url().includes("/auth")) { - throw new SmokeFailure("auth_failed", "测试账号登录后仍停留在 /auth", { url: page.url() }); - } - - try { - return await getViewerIdentity(requestContext); - } catch (error) { - throw new SmokeFailure("auth_failed", `登录后 whoami 仍不可用:${error.message}`, { url: page.url() }); - } -} - -async function waitForVisibleAny(page, selectors, label) { - try { - await page.waitForFunction(visibleSelectorScript, selectors, { timeout: UI_TIMEOUT_MS }); - } catch (error) { - throw new SmokeFailure("selector_missing", `${label} 不可见。候选 selector: ${selectors.join(", ")}`, { - selectors, - cause: error.message, - }); - } -} - -async function openPageAiDrawer(page) { - await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); - if (page.url().includes("/auth")) { - throw new SmokeFailure("auth_failed", "打开主页后被重定向到 /auth,登录态未生效", { url: page.url() }); - } - - await page.evaluate(() => { - try { - localStorage.removeItem("mnote.page_ai.openhub_host"); - localStorage.setItem("mnote.page_ai.openhub_host", "1"); - } catch {} - }); - await waitForVisibleAny(page, ["[data-testid='wolai-floating-ai']", "[data-testid='wolai-page-ai-drawer']"], "Page AI 入口"); - - const drawerVisible = await page.locator("[data-testid='wolai-page-ai-drawer']").first().isVisible().catch(() => false); - if (!drawerVisible) { - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - } - await waitForVisibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer"); -} - -async function validateOpenHubQuickActions(page) { - const frameHandle = await page.locator("iframe[data-page-ai-openhub-iframe]").elementHandle({ timeout: UI_TIMEOUT_MS }); - const frame = frameHandle ? await frameHandle.contentFrame() : null; - if (!frame) { - throw new SmokeFailure("quick_action_failed", "OpenHub iframe frame 不可用", { reason: "iframe_frame_missing" }); - } - const tabButton = frame.locator("[data-mnote-openhub-current-tab-toggle]").first(); - const folderButton = frame.locator("[data-mnote-openhub-current-folder-toggle]").first(); - await tabButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await folderButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - const fillVisibleInput = async (value) => frame.evaluate((nextValue) => { - const selectors = [ - "textarea[placeholder*='输入']", - "textarea", - "[contenteditable='true']", - "input[type='text'][placeholder*='输入']", - "input[type='text']", - ".ant-input", - ]; - const visible = (node) => { - if (!(node instanceof HTMLElement)) return false; - const style = window.getComputedStyle(node); - const rect = node.getBoundingClientRect(); - return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; - }; - for (const selector of selectors) { - const nodes = Array.from(document.querySelectorAll(selector)).filter(visible); - if (!nodes.length) continue; - const node = nodes[nodes.length - 1]; - node.focus(); - if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) { - const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(node), "value"); - if (descriptor && typeof descriptor.set === "function") { - descriptor.set.call(node, nextValue); - } else { - node.value = nextValue; - } - } else { - node.textContent = nextValue; - } - node.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" })); - node.dispatchEvent(new Event("change", { bubbles: true })); - return true; - } - return false; - }, value); - const readVisibleInput = async () => frame.evaluate(() => { - const selectors = [ - "textarea[placeholder*='输入']", - "textarea", - "[contenteditable='true']", - "input[type='text'][placeholder*='输入']", - "input[type='text']", - ".ant-input", - ]; - const visible = (node) => { - if (!(node instanceof HTMLElement)) return false; - const style = window.getComputedStyle(node); - const rect = node.getBoundingClientRect(); - return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; - }; - for (const selector of selectors) { - const nodes = Array.from(document.querySelectorAll(selector)).filter(visible); - if (!nodes.length) continue; - const node = nodes[nodes.length - 1]; - if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) return node.value || ""; - return node.textContent || ""; - } - return ""; - }); - const hasInput = await fillVisibleInput(""); - if (!hasInput) { - throw new SmokeFailure("quick_action_failed", "OpenHub iframe 内未找到可见聊天输入框", {}); - } - const prompt = `MNOTE_NATIVE_CONTEXT_SMOKE_${Date.now()}`; - let capturedBody = null; - await page.route("**/page-ai/openhub/ai/api/query/stream**", async (route) => { - capturedBody = JSON.parse(route.request().postData() || "{}"); - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: [ - `data: ${JSON.stringify({ type: "session", conversation_id: "task773-native-context", done: false })}`, - "", - `data: ${JSON.stringify({ type: "message_complete", done: true })}`, - "", - ].join("\n"), - }); - }); - await fillVisibleInput(prompt); - await tabButton.click({ timeout: UI_TIMEOUT_MS }); - const inputTextAfterToggle = (await readVisibleInput()).trim(); - const tabSelected = await tabButton.evaluate((node) => node.classList.contains("ant-btn-primary") || node.getAttribute("type") === "button" && node.matches(".ant-btn-primary")); - const folderSelectedAfterTab = await folderButton.evaluate((node) => node.classList.contains("ant-btn-primary")); - await page.keyboard.press("Enter"); - await page.waitForFunction(() => window.__mnoteOpenHubTask773RequestCaptured === true, undefined, { timeout: 100 }).catch(() => undefined); - const started = Date.now(); - while (!capturedBody && Date.now() - started < UI_TIMEOUT_MS) { - await page.waitForTimeout(100); - } - const activeEditor = await page.evaluate(() => window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === "function" - ? window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot()?.activeEditor || null - : null); - const result = { - ok: Boolean(capturedBody && capturedBody.mnote_context && capturedBody.mnote_context.value), - tabButtonVisible: await tabButton.isVisible().catch(() => false), - folderButtonVisible: await folderButton.isVisible().catch(() => false), - tabSelected, - folderSelectedAfterTab, - inputTextAfterToggle, - requestQuestion: capturedBody && capturedBody.question, - mnoteContext: capturedBody && capturedBody.mnote_context, - activeEditor, - }; - if (!result.ok) { - throw new SmokeFailure("quick_action_failed", "OpenHub 当前 Tab/文件夹上下文未随发送请求进入后台", result); - } - if (!result.tabSelected || result.folderSelectedAfterTab) { - throw new SmokeFailure("quick_action_selection_invalid", "当前 Tab 按钮没有呈现单选选中态", result); - } - if (result.inputTextAfterToggle !== prompt) { - throw new SmokeFailure("quick_action_leaked_to_input", "当前 Tab/文件夹地址不应直接写入用户可见输入框", result); - } - if (result.mnoteContext.kind !== "tab" || !/^https?:\/\/.+\/documents\//.test(result.mnoteContext.value)) { - throw new SmokeFailure("quick_action_tab_context_invalid", "当前 Tab 发送上下文不是 MNote 文档地址", result); - } - return result; -} - -async function collectState(page) { - return page.evaluate(() => { - const visible = (node) => { - if (!node || node.nodeType !== 1) return false; - const ownerWindow = node.ownerDocument && node.ownerDocument.defaultView ? node.ownerDocument.defaultView : window; - const style = ownerWindow.getComputedStyle(node); - const rect = node.getBoundingClientRect(); - return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; - }; - const rectOf = (node) => { - if (!node || node.nodeType !== 1 || typeof node.getBoundingClientRect !== "function") return null; - const rect = node.getBoundingClientRect(); - return { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.round(rect.width), - height: Math.round(rect.height), - top: Math.round(rect.top), - bottom: Math.round(rect.bottom), - }; - }; - const visibleElements = (selector) => Array.from(document.querySelectorAll(selector)).filter(visible); - const visibleAny = (selector) => visibleElements(selector).length > 0; - const text = (selector) => (document.querySelector(selector)?.textContent || "").trim(); - const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]"); - const iframeDoc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null; - const iframeBodyText = (iframeDoc && iframeDoc.body ? iframeDoc.body.textContent || "" : "").trim(); - const iframeShellKind = iframeDoc && iframeDoc.body ? iframeDoc.body.getAttribute("data-mnote-openhub-ai-shell") || "" : ""; - const drawer = Array.from(document.querySelectorAll("[data-testid='wolai-page-ai-drawer']")).find(visible) || null; - const openhubHost = document.querySelector("[data-page-ai-openhub-host='true']"); - const outerOpenHubHeaderVisible = visibleAny(".wolai-page-ai-opencode-header") - || visibleAny(".wolai-page-ai-header-copy") - || visibleAny("[data-page-ai-openhub-runtime-status]"); - const diagnostics = document.querySelector("[data-page-ai-openhub-bootstrap-copy]"); - const diagnosticsVisible = Boolean(diagnostics && visible(diagnostics)); - const diagnosticsRect = diagnosticsVisible ? rectOf(diagnostics) : null; - const diagnosticsOpen = diagnostics instanceof HTMLDetailsElement ? diagnostics.open : false; - const debugChromeRects = diagnosticsVisible ? [{ - selector: "[data-page-ai-openhub-bootstrap-copy]", - text: (diagnostics.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160), - rect: diagnosticsRect, - open: diagnosticsOpen, - }] : []; - const debugChromeTotalHeight = diagnosticsRect?.height || 0; - const fallbackActionSelector = "[data-page-ai-action='openhub-use-opencode-fallback']"; - const fallbackActionVisibleInIframe = iframeDoc - ? Array.from(iframeDoc.querySelectorAll(fallbackActionSelector)).some(visible) - : false; - const pageText = (document.body.textContent || "").replace(/\s+/g, " ").trim(); - const loginTextPattern = /(登录\s*OpenHub|OpenHub\s*Login|WeKnora\s*登录|登录\s*WeKnora|Sign in to OpenHub|OpenHub account|WeKnora account)/i; - const reactSelectorMarkers = [ - "[data-openhub-ai-panel]", - "[data-testid='openhub-ai-panel']", - "[data-testid='openhub-chat']", - "[data-openhub-conversation]", - "[data-openhub-session-history]", - ".openhub-ai-panel", - ".chat-message-list", - ".ant-layout", - ".ant-menu", - ".ant-input", - ].filter((selector) => Array.from(document.querySelectorAll(selector)).some(visible) - || (iframeDoc && Array.from(iframeDoc.querySelectorAll(selector)).some(visible))); - const reactTextMarkers = [ - "OpenHub 平台", - "开始对话", - "历史记录", - "技能管理", - "选择模型", - ].filter((marker) => iframeBodyText.includes(marker)); - const conflictEntryPattern = /(文件管理|知识库|时光机|智能体|协作任务|团队状态)/; - const quickActionTab = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-tab-toggle]") : null; - const quickActionFolder = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-current-folder-toggle]") : null; - const smokeInput = iframeDoc ? iframeDoc.querySelector("[data-mnote-openhub-smoke-input]") : null; - return { - url: location.href, - title: document.title, - bodySnippet: pageText.slice(0, 800), - drawerVisible: Boolean(drawer), - drawerRect: rectOf(drawer), - openhubHost: Boolean(openhubHost), - openhubHostRect: rectOf(openhubHost), - outerOpenHubHeaderVisible, - debugChromeVisible: debugChromeRects.length > 0, - debugChromeRects, - debugChromeTotalHeight, - debugChromeSqueezesContent: diagnosticsOpen || debugChromeTotalHeight > 80, - hostChromeVisible: Boolean(Array.from(document.querySelectorAll("[data-page-ai-openhub-bootstrap-copy]")).find(visible)), - iframeVisible: iframe instanceof HTMLIFrameElement && visible(iframe), - iframeRect: rectOf(iframe), - iframeSrc: iframe instanceof HTMLIFrameElement ? iframe.getAttribute("src") || "" : "", - iframeBodySnippet: iframeBodyText.slice(0, 800), - iframeShellKind, - runtimeStatus: text("[data-page-ai-openhub-runtime-status]"), - authTruth: text("[data-page-ai-openhub-auth-truth]"), - workspaceScope: text("[data-page-ai-openhub-workspace-scope]"), - routeGuard: text("[data-page-ai-openhub-route-guard]"), - fallback: text("[data-page-ai-openhub-fallback]"), - loginPageVisible: loginTextPattern.test(pageText) || loginTextPattern.test(iframeBodyText), - staticBoundaryVisible: iframeShellKind === "static-boundary" || /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(iframeBodyText), - fallbackActionVisible: visibleAny(fallbackActionSelector) || fallbackActionVisibleInIframe, - fallbackActionCount: document.querySelectorAll(fallbackActionSelector).length - + (iframeDoc ? iframeDoc.querySelectorAll(fallbackActionSelector).length : 0), - reactAiMarkers: [...reactSelectorMarkers, ...reactTextMarkers.map((marker) => `text:${marker}`)], - reactTextMarkers, - conflictEntryVisible: conflictEntryPattern.test(iframeBodyText), - quickActionTabVisible: Boolean(quickActionTab && visible(quickActionTab)), - quickActionFolderVisible: Boolean(quickActionFolder && visible(quickActionFolder)), - quickActionTabText: quickActionTab ? (quickActionTab.textContent || "").trim() : "", - quickActionFolderText: quickActionFolder ? (quickActionFolder.textContent || "").trim() : "", - quickActionTabLabel: quickActionTab ? quickActionTab.getAttribute("aria-label") || "" : "", - quickActionFolderLabel: quickActionFolder ? quickActionFolder.getAttribute("aria-label") || "" : "", - quickActionTabSelected: quickActionTab ? quickActionTab.classList.contains("ant-btn-primary") : false, - quickActionFolderSelected: quickActionFolder ? quickActionFolder.classList.contains("ant-btn-primary") : false, - quickActionSmokeInputValue: smokeInput instanceof HTMLTextAreaElement ? smokeInput.value : "", - }; - }); -} - -function assertOpenHubState(state) { - if (!state.drawerVisible) { - throw new SmokeFailure("selector_missing", "Page AI drawer 未保持可见", state); - } - if (!state.openhubHost) { - throw new SmokeFailure("selector_missing", "Page AI drawer 未切到 OpenHub host", state); - } - if (!state.iframeVisible || !state.iframeSrc.includes("/page-ai/openhub/ai")) { - throw new SmokeFailure("selector_missing", "OpenHub iframe 不可见或 src 未指向 /page-ai/openhub/ai", state); - } - if (state.outerOpenHubHeaderVisible) { - throw new SmokeFailure("outer_header_visible", "OpenHub drawer 仍显示 MNote 外层 OpenHub AI 标题栏", state); - } - if (!state.iframeRect || state.iframeRect.height < 420) { - throw new SmokeFailure("iframe_too_short", "OpenHub iframe 高度不足,可能被顶部 debug/status 区挤压", state); - } - if (state.debugChromeSqueezesContent) { - throw new SmokeFailure("debug_chrome_visible", "OpenHub 顶部 debug/status chrome 仍可见且挤占空间", state); - } - if (state.fallbackActionVisible || state.fallbackActionCount > 0) { - throw new SmokeFailure("legacy_fallback_visible", "OpenHub drawer 仍存在用户可见 opencode fallback 入口", state); - } - if (state.loginPageVisible) { - throw new SmokeFailure("unexpected_upstream_login", "页面出现 OpenHub/WeKnora 登录入口", state); - } - if (!state.iframeBodySnippet && !state.reactAiMarkers.length) { - throw new SmokeFailure("selector_missing", "OpenHub iframe 已出现,但 iframe 内 shell 内容不可见", state); - } - if (state.conflictEntryVisible) { - throw new SmokeFailure("unexpected_conflict_entry", "OpenHub iframe 嵌入态仍显示 MNote 真相冲突入口", state); - } - if (!state.quickActionTabVisible || !state.quickActionFolderVisible) { - throw new SmokeFailure("quick_action_missing", "OpenHub iframe 内缺少当前 Tab/文件夹快捷按钮", state); - } -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const consoleMessages = []; - let browser; - let context; - let page; - let result; - let directHostRoute = null; - let legacyOpencodeRoute = null; - const networkEvents = []; - - try { - await assertServiceReachable(BASE_URL); - browser = await chromium.launch({ - headless: process.env.HEADFUL !== "1", - ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), - }); - context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); - page = await context.newPage(); - page.on("console", (message) => { - if (["error", "warning"].includes(message.type())) { - consoleMessages.push({ type: message.type(), text: message.text() }); - } - }); - page.on("response", (response) => { - const url = response.url(); - if (url.includes("/page-ai/openhub")) { - networkEvents.push({ url, status: response.status(), contentType: response.headers()["content-type"] || "" }); - } - }); - - const viewer = await loginWithUiFirst(page, context.request).catch(async (error) => { - if (error instanceof SmokeFailure) throw error; - await ensureAuthenticated(page, context.request); - return getViewerIdentity(context.request); - }); - legacyOpencodeRoute = await context.request.get(`${BASE_URL}/page-ai/opencode`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({ - url: `${BASE_URL}/page-ai/opencode`, - status: response.status(), - ok: response.ok(), - contentType: response.headers()["content-type"] || "", - bodySnippet: (await response.text()).slice(0, 240), - })).catch((error) => ({ - url: `${BASE_URL}/page-ai/opencode`, - status: 0, - ok: false, - error: error.message, - })); - if (legacyOpencodeRoute.status !== 410) { - throw new SmokeFailure("legacy_fallback_route_enabled", "登录后 /page-ai/opencode legacy fallback 页面仍可访问", legacyOpencodeRoute); - } - directHostRoute = await context.request.get(`${BASE_URL}/page-ai/openhub/ai`, { timeout: UI_TIMEOUT_MS }).then(async (response) => ({ - url: `${BASE_URL}/page-ai/openhub/ai`, - status: response.status(), - ok: response.ok(), - contentType: response.headers()["content-type"] || "", - bodySnippet: (await response.text()).slice(0, 240), - })).catch((error) => ({ - url: `${BASE_URL}/page-ai/openhub/ai`, - status: 0, - ok: false, - error: error.message, - })); - await openPageAiDrawer(page); - await waitForVisibleAny( - page, - [ - "[data-page-ai-openhub-host='true']", - "[data-page-ai-openhub-bootstrap-copy]", - "iframe[data-page-ai-openhub-iframe]", - ], - "OpenHub host", - ); - await page.waitForFunction(() => { - const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]"); - return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("/page-ai/openhub/ai"); - }, undefined, { timeout: UI_TIMEOUT_MS }); - await page.waitForFunction(() => { - const frame = document.querySelector("iframe[data-page-ai-openhub-iframe]"); - return frame instanceof HTMLIFrameElement && (frame.getAttribute("src") || "").includes("mnoteScope="); - }, undefined, { timeout: UI_TIMEOUT_MS }).catch(() => undefined); - await page.waitForTimeout(800); - await page.waitForFunction(() => { - const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]"); - const doc = iframe instanceof HTMLIFrameElement ? iframe.contentDocument : null; - return Boolean(doc && doc.querySelector("[data-mnote-openhub-current-tab-toggle]") && doc.querySelector("[data-mnote-openhub-current-folder-toggle]")); - }, undefined, { timeout: UI_TIMEOUT_MS }); - - const quickActions = await validateOpenHubQuickActions(page); - const state = await collectState(page); - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); - assertOpenHubState(state); - const openhubReactConnected = state.reactAiMarkers.length > 0; - - result = { - ok: true, - task: TASK, - baseUrl: BASE_URL, - viewer, - screenshot: SCREENSHOT_PATH, - directHostRoute, - legacyOpencodeRoute, - quickActions, - state, - openhubReactConnected, - staticBoundaryOnly: state.staticBoundaryVisible && !openhubReactConnected, - reactAiNote: openhubReactConnected - ? "OpenHub React UI marker 已出现" - : "未发现 OpenHub React AI marker;本次只验证 MNote OpenHub host drawer/iframe/shell 边界,不把静态 shell 记为 React AI 已接入", - networkEvents, - consoleMessages, - }; - } catch (error) { - const state = page ? await collectState(page).catch(() => null) : null; - if (page) { - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined); - } - result = { - ok: false, - task: TASK, - baseUrl: BASE_URL, - screenshot: fs.existsSync(SCREENSHOT_PATH) ? SCREENSHOT_PATH : null, - failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error", - error: error instanceof Error ? error.stack || error.message : String(error), - errorDetails: error instanceof SmokeFailure ? error.details : null, - directHostRoute, - legacyOpencodeRoute, - state, - networkEvents, - consoleMessages, - }; - } finally { - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - if (context) await context.close().catch(() => undefined); - if (browser) await browser.close().catch(() => undefined); - } - - console.log(JSON.stringify(result, null, 2)); - if (!result.ok) process.exit(1); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task774-openhub-mnote-send-and-file-edit-e2e.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task774-openhub-mnote-send-and-file-edit-e2e.js deleted file mode 100644 index 8cb64a3c..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task774-openhub-mnote-send-and-file-edit-e2e.js +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - getViewerIdentity, -} = require("./tree-shell-smoke-helpers"); - -const TASK = "task774-openhub-mnote-send-and-file-edit-e2e"; -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime"); -const RESULT_PATH = path.join(OUTPUT_DIR, "openhub-send-smoke-result.json"); -const WORKSPACE_ROOT = process.env.MNOTE_OPENHUB_SMOKE_ROOT - || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; -const ROOT_URI = `file://${WORKSPACE_ROOT}`; -const TEST_EMAIL = "mnote.e2e@example.com"; -const TEST_PASSWORD = "MnoteE2E123!"; -const ENABLE_FILE_EDIT = process.env.MNOTE_OPENHUB_FILE_EDIT === "1"; -const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH - || ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"] - .find((candidate) => fs.existsSync(candidate)); - -class SmokeFailure extends Error { - constructor(kind, message, details = {}) { - super(message); - this.name = "SmokeFailure"; - this.kind = kind; - this.details = details; - } -} - -async function assertServiceReachable(baseUrl) { - let response; - try { - response = await fetch(`${baseUrl}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) }); - } catch (error) { - throw new SmokeFailure("service_unreachable", `MNote 3000 服务不可达:${error.message}`, { baseUrl }); - } - if (!response.ok && response.status !== 303) { - throw new SmokeFailure("service_unreachable", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl }); - } -} - -async function loginWithUiFirst(page, requestContext) { - await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); - if (!page.url().includes("/auth")) { - return getViewerIdentity(requestContext); - } - - const quickLogin = page.getByRole("button", { name: "测试账号快速登录" }); - if (await quickLogin.isVisible({ timeout: 8_000 }).catch(() => false)) { - await quickLogin.click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined); - } - - if (page.url().includes("/auth")) { - const account = page.locator('input[name="account"], input[type="email"], input[data-auth-field="account"]').first(); - const password = page.locator('input[name="password"], input[type="password"]').first(); - const submit = page.getByRole("button", { name: /^登录$|账号登录|登录$/ }).first(); - if (!(await account.isVisible({ timeout: 2_000 }).catch(() => false)) || !(await password.isVisible({ timeout: 2_000 }).catch(() => false))) { - throw new SmokeFailure("auth_failed", "认证页未出现快速登录,也找不到账号密码输入框", { url: page.url() }); - } - await account.fill(TEST_EMAIL, { timeout: UI_TIMEOUT_MS }); - await password.fill(TEST_PASSWORD, { timeout: UI_TIMEOUT_MS }); - await submit.click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: UI_TIMEOUT_MS, waitUntil: "commit" }).catch(() => undefined); - } - - if (page.url().includes("/auth")) { - throw new SmokeFailure("auth_failed", "测试账号登录后仍停留在 /auth", { url: page.url() }); - } - - return getViewerIdentity(requestContext); -} - -function mnoteScopeQuery(openhubIframeUrl) { - const queryStart = openhubIframeUrl.indexOf("?"); - if (queryStart < 0) return ""; - return openhubIframeUrl.slice(queryStart); -} - -function parseStream(streamText) { - let sessionId = ""; - let assistantText = ""; - const eventTypes = []; - for (const line of streamText.split(/\r?\n/)) { - if (!line.startsWith("data: ")) continue; - try { - const data = JSON.parse(line.slice(6)); - const payload = data.payload && data.payload.type - ? { type: data.payload.type, ...data.payload.properties } - : data; - if (payload.type) eventTypes.push(payload.type); - if (payload.conversation_id) sessionId = payload.conversation_id; - if (["text", "content", "assistant_message", "message"].includes(payload.type) && payload.content) { - assistantText += payload.content; - } - } catch {} - } - return { - sessionId, - eventTypes: [...new Set(eventTypes)], - assistantTextSnippet: assistantText.slice(0, 700), - }; -} - -async function bootstrapOpenHub(requestContext) { - const response = await requestContext.post(`${BASE_URL}/api/page-ai/openhub/bootstrap`, { - data: { - pageId: "task774-openhub-send-smoke", - workspaceId: "local-ws:mnote-e2e:my-space", - pageTitle: "OpenHub send smoke", - rootUri: ROOT_URI, - allowedRoots: [], - }, - timeout: UI_TIMEOUT_MS, - }); - const payload = await response.json(); - if (!response.ok() || !payload.openhubIframeUrl) { - throw new SmokeFailure("bootstrap_failed", `OpenHub bootstrap 失败:HTTP ${response.status()}`, payload); - } - return payload; -} - -async function fetchOpenHubModels(requestContext, scopeQuery) { - const response = await requestContext.get(`${BASE_URL}/page-ai/openhub/ai/api/models${scopeQuery}`, { timeout: UI_TIMEOUT_MS }); - const payload = await response.json(); - const models = payload?.data?.models || []; - const selected = models.find((model) => model.providerID === "opencodego" && model.modelID === "deepseek-v4-flash") - || models.find((model) => model.providerID === "opencode" && model.modelID === "deepseek-v4-flash-free") - || models.find((model) => model.providerID === "opencodego") - || models.find((model) => model.providerID === "opencode") - || payload?.data?.default - || models[0]; - if (!response.ok() || !models.length || !selected) { - throw new SmokeFailure("models_empty", `OpenHub /api/models 未返回可用真实模型:HTTP ${response.status()}`, payload); - } - return { - modelCount: models.length, - default: payload.data.default, - source: payload.data.source, - selected, - }; -} - -async function sendPrompt(requestContext, scopeQuery, model, prompt) { - const response = await requestContext.post(`${BASE_URL}/page-ai/openhub/ai/api/query/stream${scopeQuery}`, { - data: { - question: prompt, - conversation_id: "", - agent: "build", - model: { - providerID: model.providerID, - modelID: model.modelID, - currentUsage: model.currentUsage || 0, - monthlyLimit: model.monthlyLimit || 0, - }, - }, - headers: { "content-type": "application/json" }, - timeout: 180_000, - }); - const streamText = await response.text(); - if (!response.ok()) { - throw new SmokeFailure("query_stream_failed", `OpenHub query stream 失败:HTTP ${response.status()}`, { - snippet: streamText.slice(0, 800), - }); - } - return { - status: response.status(), - length: streamText.length, - snippet: streamText.slice(0, 1_200), - ...parseStream(streamText), - }; -} - -async function fetchMessages(requestContext, scopeQuery, sessionId) { - const response = await requestContext.get(`${BASE_URL}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(sessionId)}/messages${scopeQuery}`, { - timeout: UI_TIMEOUT_MS, - }); - const payload = await response.json(); - const messages = payload?.data || []; - if (!response.ok() || !messages.some((message) => message.role === "user")) { - throw new SmokeFailure("messages_not_persisted", `OpenHub SQLite messages 未持久化或不可读:HTTP ${response.status()}`, payload); - } - return { - status: response.status(), - count: messages.length, - roles: messages.map((message) => message.role), - last: messages.slice(-2).map((message) => ({ - role: message.role, - content: String(message.content || "").slice(0, 300), - model: message.model, - })), - }; -} - -async function runFileEditProbe(requestContext, scopeQuery, model) { - const fixtureDir = path.join(WORKSPACE_ROOT, "knowledge-rag-fixtures-7-68"); - fs.mkdirSync(fixtureDir, { recursive: true }); - const fixturePath = path.join(fixtureDir, `task774-openhub-file-edit-${Date.now()}.md`); - fs.writeFileSync(fixturePath, "# OpenHub File Edit Smoke\n\nstatus: pending\n", "utf8"); - const gitDir = path.join(WORKSPACE_ROOT, ".git"); - const gitExistedBefore = fs.existsSync(gitDir); - const prompt = [ - `请直接修改这个文件:${fixturePath}`, - "只把 `status: pending` 改成 `status: MNOTE_OPENHUB_FILE_EDIT_OK`。", - "不要改其它文件。完成后只简短说明已修改。", - ].join("\n"); - const stream = await sendPrompt(requestContext, scopeQuery, model, prompt); - const finalContent = fs.readFileSync(fixturePath, "utf8"); - const ok = finalContent.includes("status: MNOTE_OPENHUB_FILE_EDIT_OK"); - if (!ok) { - throw new SmokeFailure("file_edit_not_applied", "OpenHub/opencode 未把 fixture 文件改到期望内容", { - fixturePath, - finalContent, - stream, - }); - } - return { - fixturePath, - ok, - finalContent, - gitExistedBefore, - gitExistsAfter: fs.existsSync(gitDir), - stream, - }; -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - let browser; - let context; - let result = { ok: false, task: TASK, baseUrl: BASE_URL }; - try { - await assertServiceReachable(BASE_URL); - browser = await chromium.launch({ - headless: process.env.HEADFUL !== "1", - ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), - }); - context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); - const page = await context.newPage(); - const viewer = await loginWithUiFirst(page, context.request); - const bootstrap = await bootstrapOpenHub(context.request); - const scopeQuery = mnoteScopeQuery(bootstrap.openhubIframeUrl); - const models = await fetchOpenHubModels(context.request, scopeQuery); - const smokePrompt = `请只回复 MNOTE_OPENHUB_SMOKE_OK,不要解释。时间戳 ${Date.now()}`; - const stream = await sendPrompt(context.request, scopeQuery, models.selected, smokePrompt); - if (!stream.sessionId) { - throw new SmokeFailure("query_stream_missing_session", "OpenHub query stream 未返回 conversation_id", stream); - } - const messages = await fetchMessages(context.request, scopeQuery, stream.sessionId); - const fileEdit = ENABLE_FILE_EDIT - ? await runFileEditProbe(context.request, scopeQuery, models.selected) - : { skipped: true, reason: "设置 MNOTE_OPENHUB_FILE_EDIT=1 后执行真实文件编辑验收" }; - result = { - ok: true, - task: TASK, - baseUrl: BASE_URL, - viewer, - bootstrap: { - ok: bootstrap.ok, - authTruth: bootstrap.authTruth, - iframeUrlPrefix: bootstrap.openhubIframeUrl.slice(0, 160), - rootUri: bootstrap.scope?.workspaceScope?.rootUri, - }, - models, - stream, - messages, - fileEdit, - }; - } catch (error) { - result = { - ...result, - ok: false, - failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error", - error: error instanceof Error ? error.stack || error.message : String(error), - details: error instanceof SmokeFailure ? error.details : undefined, - }; - } finally { - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - if (context) await context.close().catch(() => undefined); - if (browser) await browser.close().catch(() => undefined); - } - console.log(JSON.stringify(result, null, 2)); - if (!result.ok) process.exit(1); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task775-openhub-mnote-scope-isolation-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task775-openhub-mnote-scope-isolation-smoke.js deleted file mode 100644 index 899a0edd..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task775-openhub-mnote-scope-isolation-smoke.js +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const TASK = "task775-openhub-mnote-scope-isolation-smoke"; -const OPENHUB_BASE_URL = (process.env.MNOTE_OPENHUB_BASE_URL || "http://127.0.0.1:18080").replace(/\/+$/, ""); -const WORKSPACE_ROOT = process.env.MNOTE_OPENHUB_SMOKE_ROOT - || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; -const ROOT_URI = process.env.MNOTE_OPENHUB_SCOPE_ROOT_URI || `file://${WORKSPACE_ROOT}`; -const WORKSPACE_KEY = process.env.MNOTE_OPENHUB_SCOPE_WORKSPACE_KEY || "local-ws:mnote-e2e:my-space"; -const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_OPENHUB_SCOPE_ISOLATION_TIMEOUT_MS || 15_000); -const STREAM_PRIME_MS = Number(process.env.MNOTE_OPENHUB_SCOPE_ISOLATION_STREAM_PRIME_MS || 3_000); - -class SmokeFailure extends Error { - constructor(kind, message, details = {}) { - super(message); - this.name = "SmokeFailure"; - this.kind = kind; - this.details = details; - } -} - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function buildScopeHeaders(ownerLabel, sessionScope) { - return { - "content-type": "application/json", - "X-MNote-User-Key": `task775:${ownerLabel}`, - "X-MNote-Workspace-Key": WORKSPACE_KEY, - "X-MNote-Session-Scope": sessionScope, - "X-MNote-Root-Uri": ROOT_URI, - "X-MNote-Page-Resource-Id": "task775-openhub-scope-isolation", - "X-MNote-Tool-Permission-Scope": JSON.stringify({ - source: TASK, - allowedRoots: [ROOT_URI], - }), - "X-MNote-WeKnora-Tool-Scope": JSON.stringify({ - source: TASK, - enabled: false, - }), - }; -} - -async function fetchWithTimeout(url, init = {}, timeoutMs = REQUEST_TIMEOUT_MS) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { - ...init, - signal: controller.signal, - }); - } finally { - clearTimeout(timer); - } -} - -async function readJsonResponse(response) { - const text = await response.text(); - try { - return text ? JSON.parse(text) : null; - } catch { - return text; - } -} - -async function assertOpenHubReachable(headers) { - const response = await fetchWithTimeout(`${OPENHUB_BASE_URL}/api/sessions?page=1&page_size=1`, { - method: "GET", - headers, - }); - const payload = await readJsonResponse(response); - if (!response.ok) { - throw new SmokeFailure("openhub_unreachable", `OpenHub MNote scope API 不可用:HTTP ${response.status}`, { - baseUrl: OPENHUB_BASE_URL, - payload, - }); - } - return { - status: response.status, - success: payload?.success === true, - }; -} - -async function primeSessionWithOwnerA(headers, sessionId, marker) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), STREAM_PRIME_MS); - let response; - let streamSnippet = ""; - try { - response = await fetch(`${OPENHUB_BASE_URL}/api/query/stream`, { - method: "POST", - headers, - body: JSON.stringify({ - question: `请只回复 ${marker},不要解释。`, - conversation_id: sessionId, - agent: "build", - model: { - providerID: process.env.MNOTE_OPENHUB_SCOPE_MODEL_PROVIDER || "opencodego", - modelID: process.env.MNOTE_OPENHUB_SCOPE_MODEL_ID || "deepseek-v4-flash", - currentUsage: 0, - monthlyLimit: 0, - }, - }), - signal: controller.signal, - }); - if (!response.ok) { - const payload = await readJsonResponse(response); - throw new SmokeFailure("query_stream_failed", `Owner A 创建 session/message 失败:HTTP ${response.status}`, { - payload, - }); - } - - if (response.body) { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - try { - while (streamSnippet.length < 2_000) { - const readResult = await Promise.race([ - reader.read(), - delay(250).then(() => ({ timedOut: true })), - ]); - if (readResult.timedOut) break; - if (readResult.done) break; - streamSnippet += decoder.decode(readResult.value, { stream: true }); - if (streamSnippet.includes(marker) || streamSnippet.includes("\"type\"")) break; - } - } finally { - await reader.cancel().catch(() => undefined); - } - } - } catch (error) { - if (error && error.name !== "AbortError") { - throw error; - } - } finally { - clearTimeout(timer); - } - - return { - status: response?.status || 0, - streamSnippet: streamSnippet.slice(0, 500), - }; -} - -async function fetchMessages(headers, sessionId) { - const response = await fetchWithTimeout( - `${OPENHUB_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/messages`, - { - method: "GET", - headers, - }, - ); - const payload = await readJsonResponse(response); - return { - status: response.status, - ok: response.ok, - payload, - }; -} - -async function waitForOwnerAMessages(headers, sessionId, marker) { - let last = null; - for (let attempt = 0; attempt < 12; attempt += 1) { - last = await fetchMessages(headers, sessionId); - const messages = Array.isArray(last.payload?.data) ? last.payload.data : []; - const hasMarker = messages.some((message) => String(message.content || "").includes(marker)); - if (last.ok && hasMarker) { - return { - status: last.status, - readable: true, - count: messages.length, - roles: messages.map((message) => message.role), - markerFound: true, - sample: messages.slice(-3).map((message) => ({ - role: message.role, - content: String(message.content || "").slice(0, 240), - })), - }; - } - await delay(500); - } - - throw new SmokeFailure("owner_a_messages_not_readable", "Owner A 未能读取到自己创建的 session/messages", { - last, - }); -} - -function assertOwnerBBlocked(ownerBResult) { - if (ownerBResult.status === 403 || ownerBResult.status === 404) { - return { - blocked: true, - status: ownerBResult.status, - detail: ownerBResult.payload?.detail || ownerBResult.payload, - }; - } - - throw new SmokeFailure("owner_b_not_blocked", "Owner B 读取到了或可访问 Owner A 的 session/messages", { - status: ownerBResult.status, - payload: ownerBResult.payload, - }); -} - -async function main() { - const runId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; - const sessionId = `task775-openhub-scope-${runId}`; - const sessionScope = `task775-openhub-scope-isolation:${runId}`; - const marker = `MNOTE_OPENHUB_SCOPE_ISOLATION_${runId}`; - const headersA = buildScopeHeaders("owner-a", sessionScope); - const headersB = buildScopeHeaders("owner-b", sessionScope); - let result = { - ok: false, - task: TASK, - mode: "direct-openhub-mnote-headers-backend-scope-isolation", - openhubBaseUrl: OPENHUB_BASE_URL, - sessionId, - }; - - try { - const health = await assertOpenHubReachable(headersA); - const stream = await primeSessionWithOwnerA(headersA, sessionId, marker); - const ownerA = await waitForOwnerAMessages(headersA, sessionId, marker); - const ownerB = assertOwnerBBlocked(await fetchMessages(headersB, sessionId)); - result = { - ...result, - ok: true, - health, - stream, - ownerA, - ownerB, - summary: { - sessionId, - ownerAReadable: ownerA.readable, - ownerBBlocked: ownerB.blocked, - ownerBStatus: ownerB.status, - }, - }; - } catch (error) { - result = { - ...result, - ok: false, - failureKind: error instanceof SmokeFailure ? error.kind : "unexpected_error", - error: error instanceof Error ? error.stack || error.message : String(error), - details: error instanceof SmokeFailure ? error.details : undefined, - }; - } - - console.log(JSON.stringify(result, null, 2)); - if (!result.ok) process.exit(1); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task776-openhub-changed-files-bridge-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task776-openhub-changed-files-bridge-smoke.js deleted file mode 100644 index 23020e41..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task776-openhub-changed-files-bridge-smoke.js +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); - -const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; -const backendSessionPath = path.join(openHubRoot, "smart-query-backend/app/api/session.py"); -const frontendApiPath = path.join(openHubRoot, "smart-query-frontend/src/services/api.js"); -const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx"); -const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"); -const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"); -const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js"); -const task774ResultPath = path.join(repoRoot, "tmp/7-68-runtime/openhub-send-smoke-result.json"); -const outputDir = path.join(repoRoot, "tmp/7-68-runtime"); -const resultPath = path.join(outputDir, "openhub-changed-files-bridge-smoke-result.json"); -const baseUrl = process.env.MNOTE_BASE_URL || "http://127.0.0.1:3000"; -const testAccount = process.env.MNOTE_E2E_ACCOUNT || "mnote-e2e"; -const testPassword = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; -const workspaceRoot = process.env.MNOTE_OPENHUB_SMOKE_ROOT - || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; - -function read(filePath) { - return fs.readFileSync(filePath, "utf8"); -} - -function assertCheck(failures, name, passed, details = undefined) { - if (!passed) failures.push({ name, details }); -} - -function loadTask774Probe() { - if (!fs.existsSync(task774ResultPath)) return null; - const payload = JSON.parse(fs.readFileSync(task774ResultPath, "utf8")); - const sessionId = payload?.fileEdit?.stream?.sessionId; - const fixturePath = payload?.fileEdit?.fixturePath; - if (!payload?.ok || !sessionId || !fixturePath) return null; - return { sessionId, fixturePath }; -} - -async function signInCookie() { - const response = await fetch(`${baseUrl}/api/auth`, { - method: "POST", - headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify({ - action: "auth:signIn", - args: { - provider: "password", - params: { - account: testAccount, - password: testPassword, - flow: "signIn", - }, - }, - }), - signal: AbortSignal.timeout(12_000), - }); - const text = await response.text(); - if (!response.ok) { - throw new Error(`/api/auth 登录失败: HTTP ${response.status} ${text.slice(0, 400)}`); - } - const setCookie = response.headers.get("set-cookie") || ""; - const cookies = setCookie - .split(/,(?=\s*[^;,\s]+=)/) - .map((part) => part.split(";")[0].trim()) - .filter(Boolean); - if (!cookies.some((cookie) => cookie.startsWith("mnote_session="))) { - throw new Error(`/api/auth 未返回 mnote_session cookie: ${setCookie.slice(0, 400)}`); - } - return cookies.join("; "); -} - -async function bootstrapScopeQuery(cookieHeader) { - const response = await fetch(`${baseUrl}/api/page-ai/openhub/bootstrap`, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json", - cookie: cookieHeader, - }, - body: JSON.stringify({ - workspaceId: "local-ws:mnote-e2e:my-space", - rootUri: `file://${workspaceRoot}`, - pageResourceId: "task776-openhub-changed-files-bridge", - pageTitle: "OpenHub changed files bridge smoke", - allowedRoots: [`file://${workspaceRoot}`], - }), - signal: AbortSignal.timeout(12_000), - }); - const payload = await response.json().catch(() => null); - if (!response.ok) { - throw new Error(`OpenHub bootstrap 失败: HTTP ${response.status} ${JSON.stringify(payload).slice(0, 500)}`); - } - const iframeUrl = String(payload?.openhubIframeUrl || ""); - const query = iframeUrl.includes("?") ? iframeUrl.slice(iframeUrl.indexOf("?")) : ""; - if (!query.includes("mnoteScope=")) { - throw new Error(`OpenHub bootstrap 未返回完整 mnoteScope query: ${iframeUrl.slice(0, 200)}`); - } - return query; -} - -async function probeLiveEndpoint(probe) { - if (!probe) { - return { skipped: true, reason: "缺少 task774 真实文件编辑结果,先运行 MNOTE_OPENHUB_FILE_EDIT=1 node scripts/task774-openhub-mnote-send-and-file-edit-e2e.js" }; - } - const cookieHeader = await signInCookie(); - const query = await bootstrapScopeQuery(cookieHeader); - const url = `${baseUrl}/page-ai/openhub/ai/api/sessions/${encodeURIComponent(probe.sessionId)}/diff${query}`; - const response = await fetch(url, { - headers: { accept: "application/json", cookie: cookieHeader }, - signal: AbortSignal.timeout(12_000), - }); - const text = await response.text(); - let payload = null; - try { - payload = JSON.parse(text); - } catch {} - const changed = Array.isArray(payload?.diffs) ? payload.diffs : []; - const matched = changed.some((item) => item.path === probe.fixturePath); - return { - skipped: false, - ok: response.ok && matched, - status: response.status, - matched, - expectedPath: probe.fixturePath, - queryFromFreshBootstrap: true, - changedPaths: changed.map((item) => item.path), - diffAvailable: payload?.diffAvailable, - source: payload?.source, - limitation: payload?.limitation, - snippet: text.slice(0, 800), - }; -} - -async function main() { - fs.mkdirSync(outputDir, { recursive: true }); - const failures = []; - const backendSession = read(backendSessionPath); - const frontendApi = read(frontendApiPath); - const diffViewer = read(diffViewerPath); - const embed = read(embedPath); - const smartQuery = read(smartQueryPath); - const mnoteRuntime = read(mnoteRuntimePath); - - assertCheck( - failures, - "OpenHub backend exposes session diff endpoint", - backendSession.includes('@router.get("/api/sessions/{session_id}/diff")') && - backendSession.includes("_changed_files_from_messages") && - backendSession.includes("opencode_tool_events") - ); - assertCheck( - failures, - "backend extracts path only from write-like opencode tool metadata", - backendSession.includes("_is_write_tool") && - backendSession.includes("_iter_tool_path_values") && - backendSession.includes('"filePath"') && - backendSession.includes('"source": "opencode_tool_event"') - ); - assertCheck( - failures, - "backend guards workspace path and does not synthesize diff content", - backendSession.includes("os.path.commonpath") && - backendSession.includes('"diffAvailable": False') && - backendSession.includes('"content": ""') && - backendSession.includes("不生成或伪造 diff") - ); - assertCheck( - failures, - "frontend service still calls session diff endpoint", - frontendApi.includes("getSessionDiff") && frontendApi.includes("/sessions/${sessionId}/diff") - ); - assertCheck( - failures, - "frontend postMessage bridge emits mnote open-file payload", - embed.includes("postMNoteOpenFile") && - embed.includes("type: 'mnote:open-file'") && - embed.includes("source: payload.source || 'openhub-diff'") - ); - assertCheck( - failures, - "DiffViewer exposes changed path open action", - diffViewer.includes("postMNoteOpenFile") && - diffViewer.includes("rootRelativePath") && - diffViewer.includes("diffAvailable") - ); - assertCheck( - failures, - "SmartQueryPage loads changed files after stream and exposes hidden bridge payload", - smartQuery.includes("loadChangedFiles(finalConversationId)") && - smartQuery.includes("data-mnote-openhub-changed-file") && - smartQuery.includes("handleOpenChangedFile") - ); - assertCheck( - failures, - "MNote host listens for OpenHub open-file bridge", - mnoteRuntime.includes("pageAiInstallMNoteOpenFileBridge") && - mnoteRuntime.includes("message.type !== 'mnote:open-file'") && - mnoteRuntime.includes("openhub-diff") && - mnoteRuntime.includes("pageAiOpenOpencodeChangedFile") - ); - - const liveProbeInput = loadTask774Probe(); - let liveProbe; - try { - liveProbe = await probeLiveEndpoint(liveProbeInput); - if (!liveProbe.skipped) { - assertCheck(failures, "live MNote proxy returns real changed path from task774 session", liveProbe.ok, liveProbe); - } - } catch (error) { - liveProbe = { - skipped: false, - ok: false, - error: error instanceof Error ? error.message : String(error), - reason: "MNote/OpenHub 服务不可达或 task774 session 已不可读", - }; - assertCheck(failures, "live MNote proxy returns real changed path from task774 session", false, liveProbe); - } - - const result = { - ok: failures.length === 0, - task: "task776-openhub-changed-files-bridge-smoke", - liveProbe, - failures, - }; - fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); - if (!result.ok) process.exit(1); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task778-openhub-artifact-index-static-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task778-openhub-artifact-index-static-smoke.js deleted file mode 100644 index 6ea7c83a..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task778-openhub-artifact-index-static-smoke.js +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); - -const repoRoot = path.resolve(__dirname, ".."); -const routePath = path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"); -const modPath = path.join(repoRoot, "rust/crates/mnote-web/src/routes/mod.rs"); -const designPath = path.join( - repoRoot, - "design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md" -); - -function read(filePath) { - return fs.readFileSync(filePath, "utf8"); -} - -function assertCheck(failures, name, passed, details = undefined) { - if (!passed) failures.push({ name, details }); -} - -const route = read(routePath); -const routesMod = read(modPath); -const design = read(designPath); -const failures = []; - -assertCheck( - failures, - "MNote exposes artifact index API under Page AI OpenHub boundary", - routesMod.includes('"/api/page-ai/openhub/artifact-index"') && - routesMod.includes("get(page_ai_openhub::artifact_index_get).post(page_ai_openhub::artifact_index_upsert)") -); - -assertCheck( - failures, - "artifact index schema stores only lightweight locator fields", - route.includes("mnote.page_ai_openhub_artifact_index_record.v1") && - route.includes('"openhubSessionId"') && - route.includes('"kind"') && - route.includes('"providerId"') && - route.includes('"path"') && - route.includes('"citationPayload"') -); - -assertCheck( - failures, - "artifact index explicitly refuses OpenHub message fulltext fields", - route.includes("reject_fulltext_message_fields") && - route.includes("page_ai_openhub_artifact_index_forbidden_fulltext_field") && - route.includes('"message"') && - route.includes('"conversationMessages"') && - route.includes('"assistantMessage"') && - route.includes('"userMessage"') && - route.includes('"content"') && - route.includes('"transcript"') -); - -assertCheck( - failures, - "artifact index response documents no message fulltext copy", - route.includes('"messageFulltextCopied": false') && - route.includes('"openhub_message_fulltext"') && - route.includes('"openhub_conversation_message_rows"') && - route.includes('"assistant_text"') && - route.includes('"user_prompt"') -); - -assertCheck( - failures, - "minimal persistence stays inside MNote/root metadata or explicit env path", - route.includes("MNOTE_OPENHUB_ARTIFACT_INDEX_PATH") && - route.includes('join(".mnote")') && - route.includes('join("page-ai-openhub-artifact-index.json")') && - route.includes("write_artifact_index_records") -); - -assertCheck( - failures, - "design checklist records completed artifact index boundary", - design.includes("[x] MNote 只存 artifact index") && - design.includes("task778-openhub-artifact-index-static-smoke.js") && - design.includes("不是复制 OpenHub SQLite message 表") -); - -const result = { - ok: failures.length === 0, - task: "task778-openhub-artifact-index-static-smoke", - failures, -}; -console.log(JSON.stringify(result, null, 2)); -if (!result.ok) process.exit(1); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js deleted file mode 100644 index 67804951..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task779-openhub-file-edit-document-pane-refresh-smoke.js +++ /dev/null @@ -1,230 +0,0 @@ -#!/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", "7-68-runtime"); -const RESULT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh-result.json"); -const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "task779-openhub-file-edit-document-pane-refresh.png"); -const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH - || ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] - .find((candidate) => fs.existsSync(candidate)); - -function fileUrl(localPath) { - return `file://${localPath}`; -} - -function localMdDocumentId(relativePath) { - return `local-md:${relativePath.replaceAll("/", "~2F")}`; -} - -function documentUrl(root, relativePath, workspaceId) { - const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); - url.searchParams.set("sourceKind", "local_folder"); - url.searchParams.set("rootUri", fileUrl(root)); - if (workspaceId) url.searchParams.set("workspaceId", workspaceId); - return url.toString(); -} - -function markdown(title, lines) { - return ["---", `title: ${title}`, "---", "", ...lines, ""].join("\n"); -} - -function writeWorkspaceManifest(root, ownerId, workspaceId) { - const metadataDir = path.join(root, ".mnote"); - fs.mkdirSync(metadataDir, { recursive: true }); - fs.writeFileSync( - path.join(metadataDir, "workspace.json"), - `${JSON.stringify({ - workspaceId, - ownerId, - createdAt: new Date().toISOString(), - capabilities: ["local_files", "markdown_edit"], - }, null, 2)}\n`, - "utf8", - ); -} - -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 readDocumentPaneState(page) { - return page.evaluate(() => { - const root = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-pane-role="primary"]'); - const pane = document.querySelector('.document-pane[data-pane-role="primary"]'); - const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror'); - const aggregateNode = document.getElementById("__MNOTE_PAGE_AGGREGATE__"); - let aggregate = null; - try { - aggregate = JSON.parse(aggregateNode?.textContent || "null"); - } catch {} - return { - paneDocumentId: pane?.getAttribute("data-pane-document-id") || "", - runtimeStatus: root?.getAttribute("data-runtime-editor-status") || "", - runtimeError: root?.getAttribute("data-runtime-editor-error") || "", - editorText: editor?.textContent || "", - aggregateText: JSON.stringify(aggregate?.body || aggregate || {}), - syncedAt: aggregateNode?.getAttribute("data-mnote-page-aggregate-synced-at") || "", - openhubRefreshMarker: document.documentElement.getAttribute("data-mnote-page-ai-openhub-document-pane-refresh") || "", - eventBusSource: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-source") || "", - eventBusReason: document.documentElement.getAttribute("data-mnote-local-folder-event-bus-last-reason") || "", - fileChangeSources: window.__mnoteTask779FileChangeSources || [], - documentSessionDebug: window.__mnoteDebugDocumentSessions?.snapshot?.() || null, - }; - }); -} - -async function openDocument(page, root, relativePath, workspaceId) { - await page.goto(documentUrl(root, relativePath, workspaceId), { waitUntil: "commit", 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 waitForPageAiRuntime(page) { - await page.waitForFunction( - () => typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function" - && typeof window.__mnoteDocumentPaneRuntime?.refreshPrimaryDocument === "function" - && typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument === "function", - null, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function installOpenHubChangedFileBridge(page) { - await page.evaluate(() => { - try { - localStorage.setItem("mnote.page_ai.openhub_host", "1"); - } catch {} - window.__mnoteSidebarPageAiRuntime.openPageAiDrawer(); - }); - await page.locator('[data-testid="wolai-page-ai-drawer"][data-page-ai-openhub-host="true"]').first().waitFor({ - state: "visible", - timeout: UI_TIMEOUT_MS, - }); -} - -async function simulateOpenHubChangedFile(page, root, relativePath, workspaceId) { - return page.evaluate(({ rootUri, path, workspaceId }) => { - window.postMessage({ - type: "mnote:open-file", - source: "openhub-changed-files", - path, - rootUri, - workspaceId, - documentId: `local-md:${path.replaceAll("/", "~2F")}`, - }, window.location.origin); - return true; - }, { rootUri: fileUrl(root), path: relativePath, workspaceId }); -} - -async function run() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task779-openhub-pane-refresh-")); - const workspaceId = `local-ws:user_real:task779:${Date.now()}`; - const relativePath = "task779-openhub-refresh.md"; - const initialText = "task779 initial document pane text"; - const changedText = `task779 openhub changed file bridge ${Date.now()}`; - writeWorkspaceManifest(root, "user_real", workspaceId); - fs.writeFileSync(path.join(root, relativePath), markdown("Task 779 OpenHub Refresh", [initialText]), "utf8"); - - const browser = await chromium.launch({ - headless: process.env.HEADFUL !== "1", - ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), - }); - const context = await browser.newContext({ - viewport: { width: 1360, height: 900 }, - locale: "zh-CN", - extraHTTPHeaders: { - "x-mnote-actor-id": "user_real", - "x-mnote-actor-type": "user", - }, - }); - const page = await context.newPage(); - const debug = { root, workspaceId, relativePath, initialText, changedText }; - - try { - await openDocument(page, root, relativePath, workspaceId); - await waitForEditorText(page, initialText); - await waitForPageAiRuntime(page); - await installOpenHubChangedFileBridge(page); - await page.evaluate(() => { - window.__mnoteTask779FileChangeSources = []; - window.addEventListener("mnote:file-change-batch", (event) => { - window.__mnoteTask779FileChangeSources.push(String(event?.detail?.source || "")); - }); - }); - debug.before = await readDocumentPaneState(page); - - fs.writeFileSync( - path.join(root, relativePath), - markdown("Task 779 OpenHub Refresh", [changedText]), - "utf8", - ); - - const opened = await simulateOpenHubChangedFile(page, root, relativePath, workspaceId); - assert.equal(opened, true, "OpenHub changed-file bridge 应接受当前 Markdown path"); - await waitForEditorText(page, changedText); - debug.after = await readDocumentPaneState(page); - assert.equal(debug.after.paneDocumentId, localMdDocumentId(relativePath), "primary document pane 应仍打开测试 Markdown"); - assert(debug.after.editorText.includes(changedText), `document pane 应显示磁盘新内容: ${debug.after.editorText}`); - assert(!debug.after.editorText.includes(initialText), `document pane 不应保留旧正文: ${debug.after.editorText}`); - assert.equal(debug.after.openhubRefreshMarker, relativePath, "应记录 OpenHub document pane refresh marker"); - assert( - debug.after.fileChangeSources.some((source) => source.includes("openhub_changed_file_bridge")), - `应通过 FileChangeService 消费 OpenHub changedFiles adapter: ${JSON.stringify(debug.after.fileChangeSources)}`, - ); - - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); - const result = { - ok: true, - task: "task779-openhub-file-edit-document-pane-refresh-smoke", - root, - relativePath, - documentId: localMdDocumentId(relativePath), - changedText, - beforeText: debug.before.editorText, - afterText: debug.after.editorText, - eventBusSource: debug.after.eventBusSource, - eventBusReason: debug.after.eventBusReason, - resultPath: RESULT_PATH, - screenshotPath: SCREENSHOT_PATH, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(`ok task779-openhub-file-edit-document-pane-refresh-smoke ${RESULT_PATH}`); - } catch (error) { - fs.writeFileSync(RESULT_PATH, `${JSON.stringify({ - ok: false, - error: String(error && error.stack || error), - debug, - }, null, 2)}\n`, "utf8"); - throw error; - } finally { - await browser.close().catch(() => {}); - fs.rmSync(root, { recursive: true, force: true }); - } -} - -run().catch((error) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task780-openhub-artifact-index-runtime-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task780-openhub-artifact-index-runtime-smoke.js deleted file mode 100644 index 1818956e..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task780-openhub-artifact-index-runtime-smoke.js +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const vm = require("node:vm"); -const { TextDecoder } = require("node:util"); - -const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; -const embedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"); -const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"); -const diffViewerPath = path.join(openHubRoot, "smart-query-frontend/src/components/DiffViewer.jsx"); -const designPath = path.join( - repoRoot, - "design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md" -); - -const forbiddenFulltextKeys = [ - "message", - "messages", - "messageContent", - "conversation", - "conversationMessages", - "assistantMessage", - "userMessage", - "content", - "text", - "transcript", -]; - -function read(filePath) { - return fs.readFileSync(filePath, "utf8"); -} - -function assertCheck(failures, name, passed, details = undefined) { - if (!passed) failures.push({ name, details }); -} - -function base64UrlJson(value) { - return Buffer.from(JSON.stringify(value), "utf8") - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - -function hasForbiddenKey(value) { - if (!value || typeof value !== "object") return false; - if (Array.isArray(value)) return value.some(hasForbiddenKey); - return Object.entries(value).some(([key, entry]) => ( - forbiddenFulltextKeys.includes(key) || hasForbiddenKey(entry) - )); -} - -async function probeEmbedRuntime(embedSource) { - const mnoteScope = { - workspaceScope: { - rootUri: "file:///tmp/mnote-artifact-root", - workspaceId: "ws-task780", - pageResourceId: "page-task780", - }, - }; - const calls = []; - const sandbox = { - console, - TextDecoder, - URLSearchParams, - Uint8Array, - window: { - location: { - pathname: "/page-ai/openhub/ai", - search: `?scope=session-task780&mnoteScope=${base64UrlJson(mnoteScope)}`, - origin: "http://127.0.0.1:3000", - }, - parent: {}, - atob: (value) => Buffer.from(value, "base64").toString("binary"), - }, - fetch: async (url, options = {}) => { - calls.push({ url, options }); - return { - ok: true, - status: 200, - json: async () => ({ ok: true }), - }; - }, - module: { exports: {} }, - exports: {}, - }; - const transformed = embedSource - .replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined") - .replace(/\bexport const /g, "const "); - vm.runInNewContext( - `${transformed}\nmodule.exports = { getMNoteArtifactIndexContext, postMNoteArtifactIndex };`, - sandbox, - { filename: embedPath } - ); - const result = await sandbox.module.exports.postMNoteArtifactIndex({ - openhubSessionId: "ses-task780", - kind: "changed_file", - providerId: "opencode_tool_event", - path: "/tmp/mnote-artifact-root/page.md", - citationPayload: { - schema: "openhub.changed_file_locator.v1", - rootRelativePath: "page.md", - diffAvailable: false, - }, - }); - const body = calls[0] ? JSON.parse(calls[0].options.body) : null; - return { result, calls, body }; -} - -async function main() { - const failures = []; - const embed = read(embedPath); - const smartQuery = read(smartQueryPath); - const diffViewer = read(diffViewerPath); - const design = read(designPath); - - assertCheck( - failures, - "mnoteEmbed posts artifact index to MNote root API", - embed.includes("postMNoteArtifactIndex") && - embed.includes("getMNoteArtifactIndexContext") && - embed.includes("fetch('/api/page-ai/openhub/artifact-index'") && - embed.includes("credentials: 'include'") - ); - assertCheck( - failures, - "artifact index payload is limited to locator fields", - ["openhubSessionId", "kind", "providerId", "path", "citationPayload", "rootUri", "workspaceId", "pageResourceId"] - .every((field) => embed.includes(field)) - ); - assertCheck( - failures, - "SmartQueryPage indexes changed files after diff metadata is loaded", - smartQuery.includes("indexChangedFiles(sessionId, files)") && - smartQuery.includes("kind: 'changed_file'") && - smartQuery.includes("schema: 'openhub.changed_file_locator.v1'") && - smartQuery.includes("providerId: file?.source || 'opencode_tool_event'") - ); - assertCheck( - failures, - "SmartQueryPage indexes WeKnora citation locator payloads", - smartQuery.includes("indexCitationArtifacts") && - smartQuery.includes("kind: 'citation'") && - smartQuery.includes("schema: 'openhub.weknora_citation_locator.v1'") && - smartQuery.includes("citation?.sourceRootRelativePath") - ); - assertCheck( - failures, - "citation payload sanitizer strips fulltext-like fields recursively", - forbiddenFulltextKeys.every((key) => smartQuery.includes(`'${key}'`)) && - smartQuery.includes("sanitizeCitationPayload(entry)") && - smartQuery.includes(".filter(([key]) => !forbiddenKeys.has(key))") - ); - assertCheck( - failures, - "changed file bridge remains connected to MNote open-file event", - diffViewer.includes("postMNoteOpenFile") && - smartQuery.includes("data-mnote-openhub-changed-file") && - smartQuery.includes("handleOpenChangedFile") - ); - assertCheck( - failures, - "design checklist records task780 runtime artifact index bridge", - design.includes("task780-openhub-artifact-index-runtime-smoke.js") && - design.includes("changed_file / citation 轻量 artifact index") - ); - - let runtimeProbe = null; - try { - runtimeProbe = await probeEmbedRuntime(embed); - assertCheck( - failures, - "runtime request uses MNote artifact-index endpoint", - runtimeProbe.calls.length === 1 && - runtimeProbe.calls[0].url === "/api/page-ai/openhub/artifact-index" && - runtimeProbe.calls[0].options.method === "POST", - runtimeProbe - ); - assertCheck( - failures, - "runtime request body contains required locator fields", - runtimeProbe.body && - runtimeProbe.body.openhubSessionId === "ses-task780" && - runtimeProbe.body.kind === "changed_file" && - runtimeProbe.body.providerId === "opencode_tool_event" && - runtimeProbe.body.path === "/tmp/mnote-artifact-root/page.md" && - runtimeProbe.body.rootUri === "file:///tmp/mnote-artifact-root" && - runtimeProbe.body.workspaceId === "ws-task780" && - runtimeProbe.body.pageResourceId === "page-task780", - runtimeProbe.body - ); - assertCheck( - failures, - "runtime request body does not contain OpenHub message fulltext fields", - runtimeProbe.body && !hasForbiddenKey(runtimeProbe.body), - runtimeProbe.body - ); - } catch (error) { - runtimeProbe = { error: error instanceof Error ? error.stack || error.message : String(error) }; - assertCheck(failures, "runtime request shape probe executes", false, runtimeProbe); - } - - const result = { - ok: failures.length === 0, - task: "task780-openhub-artifact-index-runtime-smoke", - runtimeProbe, - failures, - }; - console.log(JSON.stringify(result, null, 2)); - if (!result.ok) process.exit(1); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js deleted file mode 100644 index 1326981d..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task782-openhub-weknora-tool-citation-bridge-smoke.js +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const vm = require("node:vm"); - -const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; -const backendBridgePath = path.join(openHubRoot, "smart-query-backend/app/services/mnote_weknora.py"); -const backendApiPath = path.join(openHubRoot, "smart-query-backend/app/api/mnote_tools.py"); -const backendStreamPath = path.join(openHubRoot, "smart-query-backend/app/services/stream.py"); -const backendScopePath = path.join(openHubRoot, "smart-query-backend/app/core/mnote_scope.py"); -const frontendEmbedPath = path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"); -const assistantMessagePath = path.join(openHubRoot, "smart-query-frontend/src/components/AssistantMessage.jsx"); -const smartQueryPath = path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"); -const mnoteRuntimePath = path.join(repoRoot, "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js"); -const checklistPath = path.join(repoRoot, "design/07-ai/process/7-68-openhub-weknora-mnote-deep-fusion-checklist-v1.md"); -const outputDir = path.join(repoRoot, "tmp", "7-68-runtime"); -const resultPath = path.join(outputDir, "openhub-weknora-tool-citation-bridge-smoke-result.json"); - -function read(filePath) { - return fs.readFileSync(filePath, "utf8"); -} - -function assertCheck(failures, name, passed, details = undefined) { - if (!passed) failures.push({ name, details }); -} - -function base64UrlJson(value) { - return Buffer.from(JSON.stringify(value), "utf8") - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - -function extractFunction(source, name) { - const start = source.indexOf(`export const ${name}`); - if (start < 0) throw new Error(`找不到 ${name}`); - const next = source.indexOf("\nexport const ", start + 1); - return source.slice(start, next > start ? next : undefined); -} - -async function probeEmbedOpenReference(embedSource) { - const scope = { - workspaceScope: { - rootUri: "file:///tmp/mnote-openhub-task782", - workspaceId: "ws-task782", - pageResourceId: "page-task782", - }, - }; - const posted = []; - const sandbox = { - console, - TextDecoder, - URLSearchParams, - Uint8Array, - window: { - location: { - pathname: "/page-ai/openhub/ai", - search: `?scope=session-task782&mnoteScope=${base64UrlJson(scope)}`, - origin: "http://127.0.0.1:3000", - }, - parent: { - postMessage: (message, origin) => posted.push({ message, origin }), - }, - atob: (value) => Buffer.from(value, "base64").toString("binary"), - }, - module: { exports: {} }, - exports: {}, - }; - const transformed = embedSource - .slice(0, embedSource.indexOf("const compactObject")) - .replace(/import\.meta\.env\.VITE_API_BASE_URL/g, "undefined") - .replace(/\bexport const /g, "const "); - vm.runInNewContext( - `${transformed}\nmodule.exports = { postMNoteOpenReference };`, - sandbox, - { filename: frontendEmbedPath } - ); - const ok = sandbox.module.exports.postMNoteOpenReference({ - schema: "openhub.weknora_citation_locator.v1", - citation: { - provider: "weknora", - sourceRootRelativePath: "knowledge-rag-fixtures-7-68/task782.md", - citationLabel: "task782.md", - }, - }); - return { ok, posted }; -} - -async function main() { - const failures = []; - const backendBridge = read(backendBridgePath); - const backendApi = read(backendApiPath); - const backendStream = read(backendStreamPath); - const backendScope = read(backendScopePath); - const frontendEmbed = read(frontendEmbedPath); - const assistantMessage = read(assistantMessagePath); - const smartQuery = read(smartQueryPath); - const mnoteRuntime = read(mnoteRuntimePath); - const checklist = read(checklistPath); - - assertCheck( - failures, - "OpenHub backend exposes MNote WeKnora readonly facade endpoint", - backendApi.includes("/api/mnote/tools/call") && - backendApi.includes("call_mnote_weknora_tool") && - backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/mnote/tools/call\"") && - !backendBridge.includes("MNOTE_TOOL_CALL_ENDPOINT = \"/api/hermes/tools/mnote/call\"") - ); - assertCheck( - failures, - "backend tool call carries scope and does not expose WeKnora API key", - backendBridge.includes("capabilityScope") && - backendBridge.includes("knowledge_rag.read") && - backendBridge.includes("allowedRoots") && - backendBridge.includes("mnote_cookie") && - !backendBridge.includes("MNOTE_WEKNORA_API_KEY") - ); - assertCheck( - failures, - "OpenHub stream auto-bridges KB queries through MNote WeKnora", - backendStream.includes("auto_search_for_question") && - backendStream.includes("") && - backendStream.includes("_push_mnote_weknora_tool_event") && - backendStream.includes("mnote.weknora.search") - ); - assertCheck( - failures, - "MNote session cookie is only forwarded as trusted server header", - backendScope.includes("X-MNote-Session-Cookie") && - mnoteRuntime.includes("mnote:open-reference") && - !frontendEmbed.includes("mnote_session=") - ); - assertCheck( - failures, - "OpenHub frontend renders clickable WeKnora citations", - assistantMessage.includes("data-mnote-openhub-citation") && - assistantMessage.includes("openhub.weknora_citation_locator.v1") && - assistantMessage.includes("onOpenMNoteCitation") && - smartQuery.includes("postMNoteOpenReference") - ); - assertCheck( - failures, - "MNote host accepts citation open-reference postMessage bridge", - mnoteRuntime.includes("message.type !== 'mnote:open-file' && message.type !== 'mnote:open-reference'") && - mnoteRuntime.includes("openhub-citation") && - mnoteRuntime.includes("pageAiOpenOpencodeChangedFile") - ); - - const runtimeProbe = await probeEmbedOpenReference(frontendEmbed); - assertCheck( - failures, - "runtime postMNoteOpenReference posts mnote:open-reference", - runtimeProbe.ok && - runtimeProbe.posted.length === 1 && - runtimeProbe.posted[0].message.type === "mnote:open-reference" && - runtimeProbe.posted[0].message.source === "openhub-citation" && - runtimeProbe.posted[0].message.path.endsWith("task782.md"), - runtimeProbe - ); - assertCheck( - failures, - "checklist has task782 completion note", - checklist.includes("task782-openhub-weknora-tool-citation-bridge-smoke.js") - ); - - fs.mkdirSync(outputDir, { recursive: true }); - const result = { - ok: failures.length === 0, - task: "task782-openhub-weknora-tool-citation-bridge-smoke", - runtimeProbe, - failures, - }; - fs.writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - if (failures.length) { - console.error(JSON.stringify(result, null, 2)); - process.exit(1); - } - console.log(JSON.stringify(result, null, 2)); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task786-openhub-history-refresh-browser-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task786-openhub-history-refresh-browser-smoke.js deleted file mode 100644 index 7e53b155..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task786-openhub-history-refresh-browser-smoke.js +++ /dev/null @@ -1,555 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const { chromium } = require("playwright"); - -const TASK = "task786-openhub-history-refresh-browser-smoke"; -const BASE_URL = (process.env.BASE_URL || process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); -const STREAM_TIMEOUT_MS = Number(process.env.MNOTE_OPENHUB_STREAM_TIMEOUT_MS || 45_000); -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task786-openhub-history-refresh-browser-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json"); -const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "openhub-history-refresh.png"); -const TEST_EMAIL = "mnote.e2e@example.com"; -const TEST_PASSWORD = "MnoteE2E123!"; -const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH - || ["/usr/bin/chromium-browser", "/usr/bin/chromium", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] - .find((candidate) => fs.existsSync(candidate)); - -class SmokeFailure extends Error { - constructor(layer, message, details = {}) { - super(message); - this.name = "SmokeFailure"; - this.layer = layer; - this.details = details; - } -} - -function jsonSnippet(value, limit = 800) { - if (typeof value === "string") return value.slice(0, limit); - return JSON.stringify(value, null, 2).slice(0, limit); -} - -function openHubProxyUrl(pathname, scopeQuery, extraParams = {}) { - const url = new URL(`${BASE_URL}/page-ai/openhub/ai${pathname}`); - const scopeParams = new URLSearchParams(String(scopeQuery || "").replace(/^\?/, "")); - for (const [key, value] of scopeParams.entries()) { - url.searchParams.set(key, value); - } - for (const [key, value] of Object.entries(extraParams)) { - if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); - } - return url.toString(); -} - -async function parseJsonResponse(response, layer, label) { - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - throw new SmokeFailure(layer, `${label} 返回非 JSON:HTTP ${response.status()}`, { - status: response.status(), - bodySnippet: text.slice(0, 800), - }); - } - if (!response.ok()) { - throw new SmokeFailure(layer, `${label} 失败:HTTP ${response.status()}`, payload); - } - return payload; -} - -async function assertMNoteReachable() { - let response; - try { - response = await fetch(`${BASE_URL}/health`, { redirect: "manual", signal: AbortSignal.timeout(6_000) }); - } catch (error) { - throw new SmokeFailure("mnote_service", `MNote 3000 服务不可达:${error.message}`, { baseUrl: BASE_URL }); - } - if (!response.ok && response.status !== 303) { - throw new SmokeFailure("mnote_service", `MNote /health 返回异常:HTTP ${response.status}`, { baseUrl: BASE_URL }); - } -} - -async function signIn(requestContext) { - const response = await requestContext.post(`${BASE_URL}/api/auth`, { - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { - account: TEST_EMAIL, - email: TEST_EMAIL, - password: TEST_PASSWORD, - flow: "signIn", - }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - const payload = await parseJsonResponse(response, "auth", "/api/auth 登录"); - const whoami = await requestContext.get(`${BASE_URL}/api/auth/whoami`, { timeout: UI_TIMEOUT_MS }); - const viewer = await parseJsonResponse(whoami, "auth", "/api/auth/whoami"); - if (!viewer || !viewer.userId) { - throw new SmokeFailure("auth", "登录后 whoami 缺少 userId", { loginPayload: payload, whoami: viewer }); - } - return viewer; -} - -async function visibleAny(page, selectors, label) { - await page.waitForFunction((candidateSelectors) => { - const visible = (node) => { - if (!(node instanceof HTMLElement)) return false; - const style = window.getComputedStyle(node); - const rect = node.getBoundingClientRect(); - return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; - }; - return candidateSelectors.some((selector) => Array.from(document.querySelectorAll(selector)).some(visible)); - }, selectors, { timeout: UI_TIMEOUT_MS }).catch((error) => { - throw new SmokeFailure("mnote_page_ai_ui", `${label} 不可见`, { selectors, cause: error.message }); - }); -} - -async function openMNoteOpenHubDrawer(page) { - await page.goto(BASE_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS }); - if (page.url().includes("/auth")) { - throw new SmokeFailure("auth", "打开 MNote 主页后仍被重定向到 /auth", { url: page.url() }); - } - - await page.evaluate(() => { - try { - localStorage.setItem("mnote.page_ai.openhub_host", "1"); - } catch {} - }); - - await page.waitForFunction(() => ( - typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function" - || document.querySelector("[data-testid='wolai-floating-ai']") - || document.querySelector("[data-testid='wolai-page-ai-drawer']") - ), null, { timeout: UI_TIMEOUT_MS }); - - const openedByRuntime = await page.evaluate(() => { - try { - localStorage.setItem("mnote.page_ai.openhub_host", "1"); - if (typeof window.__mnoteSidebarPageAiRuntime?.openPageAiDrawer === "function") { - window.__mnoteSidebarPageAiRuntime.openPageAiDrawer(); - return true; - } - } catch {} - return false; - }); - - if (!openedByRuntime) { - const floating = page.locator("[data-testid='wolai-floating-ai']").first(); - if (await floating.isVisible({ timeout: 5_000 }).catch(() => false)) { - await floating.click({ timeout: UI_TIMEOUT_MS }); - } - } - - await visibleAny(page, ["[data-testid='wolai-page-ai-drawer']"], "Page AI drawer"); - await page.waitForFunction(() => { - const drawer = document.querySelector("[data-testid='wolai-page-ai-drawer']"); - return drawer instanceof HTMLElement && drawer.getAttribute("data-page-ai-openhub-host") === "true"; - }, null, { timeout: UI_TIMEOUT_MS }).catch((error) => { - throw new SmokeFailure("mnote_page_ai_ui", "Page AI drawer 未切到 OpenHub host", { - cause: error.message, - drawerText: "", - }); - }); - - await page.waitForFunction(() => { - const iframe = document.querySelector("iframe[data-page-ai-openhub-iframe]"); - return iframe instanceof HTMLIFrameElement && (iframe.getAttribute("src") || "").includes("/page-ai/openhub/ai"); - }, null, { timeout: UI_TIMEOUT_MS }).catch((error) => { - throw new SmokeFailure("mnote_page_ai_ui", "OpenHub iframe 未挂载或 src 未指向 /page-ai/openhub/ai", { cause: error.message }); - }); -} - -async function getOpenHubFrame(page) { - const deadline = Date.now() + UI_TIMEOUT_MS; - let lastState = null; - while (Date.now() < deadline) { - const handle = await page.locator("iframe[data-page-ai-openhub-iframe]").first().elementHandle().catch(() => null); - if (handle) { - const frame = await handle.contentFrame(); - lastState = { - iframeSrc: await handle.getAttribute("src").catch(() => ""), - frameUrl: frame ? frame.url() : "", - }; - if (frame && frame.url().includes("/page-ai/openhub/ai")) { - await frame.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined); - return frame; - } - } - await page.waitForTimeout(300); - } - throw new SmokeFailure("openhub_iframe_ui", "无法取得 OpenHub iframe frame", lastState || {}); -} - -async function collectFrameState(frame, marker) { - return frame.evaluate((expectedMarker) => { - const text = (selector) => (document.querySelector(selector)?.textContent || "").replace(/\s+/g, " ").trim(); - const bodyText = (document.body?.textContent || "").replace(/\s+/g, " ").trim(); - const drawerText = Array.from(document.querySelectorAll(".ant-drawer, [role='dialog']")) - .map((node) => (node.textContent || "").replace(/\s+/g, " ").trim()) - .filter(Boolean) - .join("\n"); - const messageAreaText = text(".messages-area"); - const historyButtons = Array.from(document.querySelectorAll("button")) - .map((button) => (button.textContent || button.getAttribute("title") || button.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim()) - .filter(Boolean); - return { - url: location.href, - title: document.title, - bodySnippet: bodyText.slice(0, 1200), - iframeShellKind: document.body?.getAttribute("data-mnote-openhub-ai-shell") || "", - hasReactRoot: Boolean(document.getElementById("root")), - hasOpenHubTitle: bodyText.includes("OpenHub 平台"), - hasHistoryButton: historyButtons.some((entry) => entry.includes("历史记录")), - historyButtons, - drawerTextSnippet: drawerText.slice(0, 1200), - messageAreaSnippet: messageAreaText.slice(0, 1200), - bodyHasMarker: bodyText.includes(expectedMarker), - drawerHasMarker: drawerText.includes(expectedMarker), - messageAreaHasMarker: messageAreaText.includes(expectedMarker), - staticBoundaryVisible: /静态占位|static-boundary|最小 host\/bootstrap 占位/.test(bodyText), - loginVisible: /(登录\s*OpenHub|OpenHub\s*Login|Sign in to OpenHub|WeKnora\s*登录)/i.test(bodyText), - }; - }, marker); -} - -function scopeQueryFromFrameUrl(frameUrl) { - const url = new URL(frameUrl, BASE_URL); - const query = url.searchParams.toString(); - if (!query || !url.searchParams.get("scope") || !url.searchParams.get("mnoteScope")) { - throw new SmokeFailure("openhub_scope", "OpenHub iframe URL 缺少 scope/mnoteScope", { frameUrl }); - } - return `?${query}`; -} - -function parseStreamEvents(streamText) { - const eventTypes = []; - let returnedSessionId = ""; - let errorEvent = ""; - for (const line of String(streamText || "").split(/\r?\n/)) { - if (!line.startsWith("data: ")) continue; - try { - const data = JSON.parse(line.slice(6)); - const payload = data.payload && data.payload.type - ? { type: data.payload.type, ...data.payload.properties } - : data; - if (payload.type) eventTypes.push(payload.type); - if (payload.conversation_id) returnedSessionId = payload.conversation_id; - if (payload.error) errorEvent = String(payload.error); - } catch {} - } - return { - eventTypes: [...new Set(eventTypes)], - returnedSessionId, - errorEvent, - snippet: String(streamText || "").slice(0, 1200), - }; -} - -async function sendMarkerMessage(requestContext, scopeQuery, sessionId, marker) { - const prompt = `${marker} Page AI OpenHub history refresh smoke. 请只回复 ${marker},不要解释。`; - const body = { - question: prompt, - conversation_id: sessionId, - agent: "build", - }; - const url = openHubProxyUrl("/api/query/stream", scopeQuery); - try { - const response = await requestContext.post(url, { - data: body, - headers: { "content-type": "application/json" }, - timeout: STREAM_TIMEOUT_MS, - }); - const text = await response.text(); - return { - ok: response.ok(), - status: response.status(), - prompt, - ...parseStreamEvents(text), - }; - } catch (error) { - return { - ok: false, - status: 0, - prompt, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -async function fetchMessagesUntilMarker(requestContext, scopeQuery, sessionId, marker) { - let lastPayload = null; - let lastStatus = 0; - for (let attempt = 0; attempt < 12; attempt += 1) { - const response = await requestContext.get(openHubProxyUrl(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, scopeQuery), { - timeout: UI_TIMEOUT_MS, - }); - lastStatus = response.status(); - const payload = await response.json().catch(async () => ({ nonJson: await response.text().catch(() => "") })); - lastPayload = payload; - const messages = Array.isArray(payload?.data) ? payload.data : []; - const userMessage = messages.find((message) => message.role === "user" && String(message.content || "").includes(marker)); - if (response.ok() && userMessage) { - return { - ok: true, - status: response.status(), - count: messages.length, - roles: messages.map((message) => message.role), - userMessageContent: String(userMessage.content || ""), - lastMessages: messages.slice(-3).map((message) => ({ - role: message.role, - content: String(message.content || "").slice(0, 300), - created_at: message.created_at, - })), - }; - } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - throw new SmokeFailure("openhub_api_messages", "OpenHub API 未读回带 marker 的 user message", { - sessionId, - marker, - status: lastStatus, - payloadSnippet: jsonSnippet(lastPayload), - }); -} - -async function fetchSessionsUntilMarker(requestContext, scopeQuery, sessionId, marker) { - let lastPayload = null; - for (let attempt = 0; attempt < 8; attempt += 1) { - const response = await requestContext.get(openHubProxyUrl("/api/sessions", scopeQuery, { page: 1, page_size: 10 }), { - timeout: UI_TIMEOUT_MS, - }); - const payload = await response.json().catch(async () => ({ nonJson: await response.text().catch(() => "") })); - lastPayload = payload; - const sessions = Array.isArray(payload?.data) ? payload.data : []; - const found = sessions.find((session) => String(session.session_id || session.id || "") === sessionId); - const markerTitle = sessions.find((session) => String(session.title || "").includes(marker)); - if (response.ok() && (found || markerTitle)) { - return { - ok: true, - status: response.status(), - total: payload?.pagination?.total, - foundSession: found || markerTitle, - markerInTitle: Boolean(markerTitle), - firstSessions: sessions.slice(0, 5).map((session) => ({ - session_id: session.session_id || session.id || "", - title: session.title || "", - updated_at: session.updated_at || "", - })), - }; - } - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - throw new SmokeFailure("openhub_api_history", "OpenHub sessions API 未读回对应 session/history", { - sessionId, - marker, - payloadSnippet: jsonSnippet(lastPayload), - }); -} - -async function assertOpenHubReactUsable(frame, marker) { - const state = await collectFrameState(frame, marker); - if (state.staticBoundaryVisible) { - throw new SmokeFailure("openhub_iframe_ui", "OpenHub iframe 仍是静态边界页,不能把 API-only 记为 UI 通过", state); - } - if (state.loginVisible) { - throw new SmokeFailure("openhub_iframe_ui", "OpenHub iframe 出现独立登录入口", state); - } - if (!state.hasOpenHubTitle && !state.hasHistoryButton) { - throw new SmokeFailure("openhub_iframe_ui", "OpenHub React AI 面板未渲染出历史入口", state); - } - return state; -} - -async function openHistoryAndVerify(frame, sessionId, marker) { - let beforeHistoryState = await collectFrameState(frame, marker); - if (beforeHistoryState.messageAreaHasMarker) { - return { - uiMarkerVerified: true, - historyMarkerVerified: false, - currentMessageMarkerVerified: true, - beforeHistoryState, - afterHistoryState: beforeHistoryState, - afterSessionClickState: beforeHistoryState, - }; - } - - let historyButton = frame.getByRole("button", { name: /历史记录/ }).first(); - if (!(await historyButton.isVisible({ timeout: 3_000 }).catch(() => false))) { - historyButton = frame.locator('button[title="历史记录"]').first(); - } - if (!(await historyButton.isVisible({ timeout: 8_000 }).catch(() => false))) { - throw new SmokeFailure("openhub_history_ui", "OpenHub iframe 内未找到历史记录按钮", beforeHistoryState); - } - await historyButton.click({ timeout: UI_TIMEOUT_MS }); - - let afterHistoryState = null; - for (let attempt = 0; attempt < 12; attempt += 1) { - afterHistoryState = await collectFrameState(frame, marker); - if (afterHistoryState.drawerHasMarker || afterHistoryState.bodyHasMarker) break; - if (attempt === 2) { - const refreshButton = frame.getByRole("button", { name: /刷新/ }).first(); - if (await refreshButton.isVisible({ timeout: 1_000 }).catch(() => false)) { - await refreshButton.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined); - } - } - await frame.waitForTimeout(1000); - } - - const historyMarkerVerified = Boolean(afterHistoryState && (afterHistoryState.drawerHasMarker || afterHistoryState.bodyHasMarker)); - if (!historyMarkerVerified) { - throw new SmokeFailure("openhub_history_ui", "刷新后 OpenHub history/session UI 未显示 marker/session 标题", { - sessionId, - marker, - beforeHistoryState, - afterHistoryState, - }); - } - - const markerText = frame.getByText(marker, { exact: false }).first(); - if (await markerText.isVisible({ timeout: 5_000 }).catch(() => false)) { - await markerText.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined); - } - - let afterSessionClickState = null; - for (let attempt = 0; attempt < 10; attempt += 1) { - afterSessionClickState = await collectFrameState(frame, marker); - if (afterSessionClickState.messageAreaHasMarker) break; - await frame.waitForTimeout(1000); - } - - return { - uiMarkerVerified: historyMarkerVerified || Boolean(afterSessionClickState?.messageAreaHasMarker), - historyMarkerVerified, - currentMessageMarkerVerified: Boolean(afterSessionClickState?.messageAreaHasMarker), - beforeHistoryState, - afterHistoryState, - afterSessionClickState, - }; -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const marker = `TASK786_MARKER_${suffix}`; - const sessionId = `task786-openhub-history-${suffix}`; - let browser; - let context; - let page; - let result = { - ok: false, - task: TASK, - baseUrl: BASE_URL, - sessionId, - marker, - screenshotPath: SCREENSHOT_PATH, - uiMarker: "", - historyMarker: "", - uiMarkerVerified: false, - historyMarkerVerified: false, - currentMessageMarkerVerified: false, - }; - - try { - await assertMNoteReachable(); - browser = await chromium.launch({ - headless: process.env.HEADFUL !== "1", - ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), - }); - context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" }); - page = await context.newPage(); - - const viewer = await signIn(context.request); - await openMNoteOpenHubDrawer(page); - let frame = await getOpenHubFrame(page); - const initialFrameState = await assertOpenHubReactUsable(frame, marker); - const scopeQuery = scopeQueryFromFrameUrl(frame.url()); - - const sendStream = await sendMarkerMessage(context.request, scopeQuery, sessionId, marker); - const apiMessages = await fetchMessagesUntilMarker(context.request, scopeQuery, sessionId, marker); - const apiSessions = await fetchSessionsUntilMarker(context.request, scopeQuery, sessionId, marker); - - await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS }); - await openMNoteOpenHubDrawer(page); - frame = await getOpenHubFrame(page); - const refreshedFrameState = await assertOpenHubReactUsable(frame, marker); - const ui = await openHistoryAndVerify(frame, sessionId, marker); - - if (!ui.uiMarkerVerified) { - throw new SmokeFailure("openhub_history_ui", "API 已读回 marker,但刷新后 UI 未恢复 marker/session", { - sessionId, - marker, - ui, - }); - } - - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); - result = { - ...result, - ok: true, - viewer, - scopeQueryKeys: Array.from(new URLSearchParams(scopeQuery.slice(1)).keys()), - sendStream, - apiMessageVerified: true, - apiHistoryVerified: true, - apiMessages, - apiSessions, - initialFrameState, - refreshedFrameState, - uiMarkerVerified: ui.uiMarkerVerified, - historyMarkerVerified: ui.historyMarkerVerified, - currentMessageMarkerVerified: ui.currentMessageMarkerVerified, - uiMarker: ui.currentMessageMarkerVerified ? marker : "", - historyMarker: ui.historyMarkerVerified ? marker : "", - beforeHistoryState: ui.beforeHistoryState, - afterHistoryState: ui.afterHistoryState, - afterSessionClickState: ui.afterSessionClickState, - resultPath: RESULT_PATH, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - if (fs.existsSync(FAILURE_PATH)) fs.rmSync(FAILURE_PATH, { force: true }); - } catch (error) { - if (page) { - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined); - } - result = { - ...result, - ok: false, - failureLayer: error instanceof SmokeFailure ? error.layer : "unexpected", - error: error instanceof Error ? error.stack || error.message : String(error), - details: error instanceof SmokeFailure ? error.details : undefined, - failurePath: FAILURE_PATH, - }; - fs.writeFileSync(FAILURE_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - if (fs.existsSync(RESULT_PATH)) fs.rmSync(RESULT_PATH, { force: true }); - } finally { - if (context) await context.close().catch(() => undefined); - if (browser) await browser.close().catch(() => undefined); - } - - console.log(JSON.stringify(result, null, 2)); - if (!result.ok) process.exit(1); -} - -main().catch((error) => { - const failure = { - ok: false, - task: TASK, - baseUrl: BASE_URL, - failureLayer: "fatal", - error: error instanceof Error ? error.stack || error.message : String(error), - failurePath: FAILURE_PATH, - }; - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - fs.writeFileSync(FAILURE_PATH, `${JSON.stringify(failure, null, 2)}\n`, "utf8"); - console.error(JSON.stringify(failure, null, 2)); - process.exit(1); -}); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task791-openhub-native-mnote-context-static-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task791-openhub-native-mnote-context-static-smoke.js deleted file mode 100644 index b3c717d3..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task791-openhub-native-mnote-context-static-smoke.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); - -const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; -const files = { - chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"), - embed: path.join(openHubRoot, "smart-query-frontend/src/mnoteEmbed.js"), - api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"), - queryModel: path.join(openHubRoot, "smart-query-backend/app/models/query.py"), - queryApi: path.join(openHubRoot, "smart-query-backend/app/api/query.py"), - stream: path.join(openHubRoot, "smart-query-backend/app/services/stream.py"), - mnoteRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"), -}; - -function read(file) { - return fs.readFileSync(file, "utf8"); -} - -function assertCheck(failures, name, passed) { - if (!passed) failures.push(name); -} - -const chatInput = read(files.chatInput); -const embed = read(files.embed); -const api = read(files.api); -const queryModel = read(files.queryModel); -const queryApi = read(files.queryApi); -const stream = read(files.stream); -const mnoteRoute = read(files.mnoteRoute); -const failures = []; - -assertCheck( - failures, - "OpenHub ChatInput natively owns MNote context toggles", - chatInput.includes("requestMNoteActiveTabAddress") && - chatInput.includes("data-mnote-openhub-current-tab-toggle") && - chatInput.includes("data-mnote-openhub-current-folder-toggle") && - chatInput.includes("mnoteContextMode === 'tab'") && - chatInput.includes("mnoteContextMode === 'folder'") && - chatInput.includes("handleSendWithMNoteContext") -); -assertCheck( - failures, - "toggles live beside model quota controls and do not write textarea", - chatInput.indexOf("model?.monthlyLimit") < chatInput.indexOf("data-mnote-openhub-current-tab-toggle") && - chatInput.includes("handleSend(undefined, context ?") && - !chatInput.includes("setQuestion(context.value") && - !chatInput.includes("setQuestion(mnoteContext") -); -assertCheck( - failures, - "OpenHub mnoteEmbed requests active tab/folder from MNote host", - embed.includes("export const requestMNoteActiveTabAddress") && - embed.includes("mnote:get-active-tab-address") && - embed.includes("mnote:active-tab-address") && - embed.includes("kind === 'folder' ? 'folder' : 'tab'") -); -assertCheck( - failures, - "OpenHub request body carries hidden mnote_context", - api.includes("mnoteContext = null") && - api.includes("requestBody.mnote_context = mnoteContext") -); -assertCheck( - failures, - "OpenHub backend accepts and forwards mnote_context", - queryModel.includes("mnote_context: Optional[dict]") && - queryApi.includes("mnote_context=request.mnote_context") && - stream.includes("mnote_context: Optional[dict] = None") -); -assertCheck( - failures, - "OpenHub stream prepends hidden current page/folder context before prompt", - stream.includes("") && - stream.includes("当前文件夹") && - stream.includes("当前页面") && - stream.includes("context_parts.append") && - stream.includes("_build_mnote_context_block") && - stream.includes("_mnote_context_from_scope") && - stream.includes("Effective MNote context") && - stream.includes("Sent prompt preview") -); -assertCheck( - failures, - "MNote proxy no longer injects floating quick action overlay", - !mnoteRoute.includes("data-mnote-openhub-ai-quick-actions") && - !mnoteRoute.includes("mnote_openhub_bridge_markup") && - !mnoteRoute.includes("insertAddress(") -); - -if (failures.length) { - console.error("OpenHub native MNote context static smoke failed:"); - for (const failure of failures) console.error(`- ${failure}`); - process.exit(1); -} - -console.log("OpenHub native MNote context static smoke passed."); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task792-openhub-native-mnote-context-source-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task792-openhub-native-mnote-context-source-smoke.js deleted file mode 100644 index ce5fafb6..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task792-openhub-native-mnote-context-source-smoke.js +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); - -const repoRoot = path.resolve(__dirname, ".."); -const openHubRoot = - process.env.OPENHUB_RESEARCH_ROOT || "/mnt/Data1T/Mnote_data/openhub/OpenHub"; - -const files = { - chatInput: path.join(openHubRoot, "smart-query-frontend/src/components/ChatInput.jsx"), - smartQueryPage: path.join(openHubRoot, "smart-query-frontend/src/pages/SmartQueryPage.jsx"), - api: path.join(openHubRoot, "smart-query-frontend/src/services/api.js"), - openHubFrontendSrc: path.join(openHubRoot, "smart-query-frontend/src"), - mnoteOpenHubRoute: path.join(repoRoot, "rust/crates/mnote-web/src/routes/page_ai_openhub.rs"), -}; - -function read(file) { - return fs.readFileSync(file, "utf8"); -} - -function walkFiles(dir, result = []) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walkFiles(fullPath, result); - } else { - result.push(fullPath); - } - } - return result; -} - -function extractBetween(source, startNeedle, endNeedle) { - const start = source.indexOf(startNeedle); - if (start < 0) return ""; - const end = source.indexOf(endNeedle, start); - return source.slice(start, end < 0 ? undefined : end); -} - -function check(failures, name, passed, detail = "") { - if (!passed) { - failures.push(detail ? `${name}: ${detail}` : name); - } -} - -const chatInput = read(files.chatInput); -const smartQueryPage = read(files.smartQueryPage); -const api = read(files.api); -const controlRow = extractBetween(chatInput, ""); -const handleSend = extractBetween(smartQueryPage, "const handleSend = async", "const handleKeyPress"); - -const legacyFloatingSelectors = [ - "mnote-openhub-context-bar", - "mnote-openhub-native-context", - "data-mnote-openhub-ai-quick-actions", - "mnote_openhub_bridge_markup", - "insertAddress(", -]; - -const frontendLegacyHits = []; -for (const file of walkFiles(files.openHubFrontendSrc)) { - const rel = path.relative(openHubRoot, file); - const source = read(file); - for (const selector of legacyFloatingSelectors) { - if (source.includes(selector)) { - frontendLegacyHits.push(`${rel} -> ${selector}`); - } - } -} - -const mnoteRoute = fs.existsSync(files.mnoteOpenHubRoute) ? read(files.mnoteOpenHubRoute) : ""; -const mnoteRouteLegacyHits = legacyFloatingSelectors.filter((selector) => - mnoteRoute.includes(selector) -); - -const failures = []; - -check( - failures, - "ChatInput owns two native MNote context buttons", - chatInput.includes("isMNoteOpenHubEmbed()") && - chatInput.includes("requestMNoteActiveTabAddress") && - chatInput.includes("data-mnote-openhub-current-tab-toggle") && - chatInput.includes("data-mnote-openhub-current-folder-toggle"), - "missing embed gate, context request helper, or native button data attributes" -); - -check( - failures, - "MNote context buttons are in the controls row beside agent/model controls", - controlRow.includes("\}/.test(controlRow) && - /icon=\{\}/.test(controlRow) && - !/>当前页面<\/Button>/.test(controlRow) && - !/>文件夹<\/Button>/.test(controlRow), - "expected two icon-only buttons with distinct tooltip and aria labels" -); - -check( - failures, - "ChatInput sends mnote_context as hidden metadata instead of writing URL into textarea question", - /handleSend\(undefined,\s*context\s*\?\s*\{/.test(handleMNoteSend) && - handleMNoteSend.includes("kind: mnoteContextMode") && - handleMNoteSend.includes("value: context.value") && - !/setQuestion\s*\(\s*context\./.test(handleMNoteSend) && - !/setQuestion\s*\(\s*mnoteContext/.test(handleMNoteSend) && - !/question\s*=\s*context\.value/.test(handleMNoteSend), - "context must flow through handleSend second argument and must not mutate the visible question value" -); - -check( - failures, - "SmartQueryPage accepts mnote_context and forwards it to queryDataService.queryDataStream", - /const\s+handleSend\s*=\s*async\s*\(\s*overrideQuestion\s*,\s*mnoteContext\s*=\s*null\s*\)/.test( - smartQueryPage - ) && - handleSend.includes("queryDataService.queryDataStream(") && - /abortControllerRef\.current\.signal\s*,\s*mnoteContext/.test(handleSend), - "handleSend must accept mnoteContext and pass it through the stream send call" -); - -check( - failures, - "OpenHub send body carries hidden mnote_context", - /queryDataStream:\s*async\s*\([^)]*mnoteContext\s*=\s*null/.test(api) && - api.includes("requestBody.mnote_context = mnoteContext") && - !/question\s*:\s*.*mnoteContext/.test(api), - "api.js must put mnoteContext into requestBody.mnote_context, not append it to question" -); - -check( - failures, - "OpenHub frontend no longer depends on legacy MNote floating button selectors", - frontendLegacyHits.length === 0, - frontendLegacyHits.join("; ") -); - -check( - failures, - "MNote OpenHub route no longer injects legacy floating context buttons", - mnoteRouteLegacyHits.length === 0, - mnoteRouteLegacyHits.join("; ") -); - -if (failures.length) { - console.error("OpenHub native MNote context source smoke failed:"); - for (const failure of failures) console.error(`- ${failure}`); - process.exit(1); -} - -console.log("OpenHub native MNote context source smoke passed."); diff --git a/recycle/20260712-pi-ts-openhub-retirement/scripts/task793-openhub-mnote-context-fastapi-chain-smoke.js b/recycle/20260712-pi-ts-openhub-retirement/scripts/task793-openhub-mnote-context-fastapi-chain-smoke.js deleted file mode 100644 index 39d58bd7..00000000 --- a/recycle/20260712-pi-ts-openhub-retirement/scripts/task793-openhub-mnote-context-fastapi-chain-smoke.js +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const http = require("node:http"); -const { spawn } = require("node:child_process"); - -const OPENHUB_BACKEND_DIR = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend"; -const OPENCODE_PORT = Number(process.env.TASK793_OPENCODE_PORT || 19096); -const OPENHUB_PORT = Number(process.env.TASK793_OPENHUB_PORT || 18181); -const SESSION_ID = "ses_mnote_context_fullchain"; - -function wait(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function readBody(req) { - return new Promise((resolve) => { - let data = ""; - req.on("data", (chunk) => { data += chunk; }); - req.on("end", () => resolve(data)); - }); -} - -function sseEvent(type, properties) { - return `data: ${JSON.stringify({ payload: { type, properties } })}\n\n`; -} - -function startFakeOpencode() { - const capturedPrompts = []; - let eventResponse = null; - function sendEvents() { - if (!eventResponse) return false; - eventResponse.write(sseEvent("message.updated", { - sessionID: SESSION_ID, - info: { id: "msg_assistant", role: "assistant", sessionID: SESSION_ID }, - })); - eventResponse.write(sseEvent("message.part.updated", { - sessionID: SESSION_ID, - part: { id: "prt_text", messageID: "msg_assistant", sessionID: SESSION_ID, type: "text", text: "OK" }, - })); - eventResponse.write(sseEvent("session.status", { - sessionID: SESSION_ID, - status: { type: "idle" }, - })); - eventResponse.end(); - eventResponse = null; - return true; - } - const server = http.createServer(async (req, res) => { - const url = new URL(req.url, `http://127.0.0.1:${OPENCODE_PORT}`); - if (req.method === "GET" && url.pathname === "/global/health") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - return; - } - if (req.method === "POST" && url.pathname === "/session") { - await readBody(req); - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ id: SESSION_ID })); - return; - } - if (req.method === "GET" && url.pathname === "/global/event") { - res.writeHead(200, { - "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache", - connection: "keep-alive", - }); - eventResponse = res; - eventResponse.write(`data: ${JSON.stringify({ payload: { type: "ready", properties: { sessionID: SESSION_ID } } })}\n\n`); - if (capturedPrompts.length) { - setTimeout(sendEvents, 50); - } - return; - } - if (req.method === "POST" && url.pathname === `/session/${SESSION_ID}/prompt_async`) { - const body = JSON.parse(await readBody(req) || "{}"); - capturedPrompts.push(body); - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - setTimeout(sendEvents, 50); - return; - } - res.writeHead(404, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "not_found", path: url.pathname })); - }); - return { - capturedPrompts, - listen: () => new Promise((resolve) => server.listen(OPENCODE_PORT, "127.0.0.1", resolve)), - close: () => new Promise((resolve) => server.close(resolve)), - }; -} - -async function waitForHealth(url, timeoutMs = 20_000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); - if (response.status < 500) return; - } catch {} - await wait(250); - } - throw new Error(`等待服务超时: ${url}`); -} - -async function sendOpenHub(payload, extraHeaders = {}) { - const body = JSON.stringify(payload); - return new Promise((resolve, reject) => { - const req = http.request({ - hostname: "127.0.0.1", - port: OPENHUB_PORT, - path: "/api/query/stream", - method: "POST", - headers: { - "content-type": "application/json", - "content-length": Buffer.byteLength(body), - "x-mnote-user-key": "openhub_user_test", - "x-mnote-workspace-key": "workspace_test", - "x-mnote-session-scope": `session_${Date.now()}_${Math.random().toString(16).slice(2)}`, - "x-mnote-root-uri": "file:///tmp/mnote-fullchain-workspace", - "x-mnote-page-resource-id": "local-md:Inbox~2FPage.md", - ...extraHeaders, - }, - }, (res) => { - let text = ""; - const timeout = setTimeout(() => { - req.destroy(); - resolve(text); - }, 10_000); - res.setEncoding("utf8"); - res.on("data", (chunk) => { - text += chunk; - if (text.includes("message_complete")) { - clearTimeout(timeout); - req.destroy(); - resolve(text); - } - }); - res.on("end", () => { - clearTimeout(timeout); - if (res.statusCode < 200 || res.statusCode >= 300) { - reject(new Error(`OpenHub HTTP ${res.statusCode}: ${text.slice(0, 500)}`)); - } else { - resolve(text); - } - }); - }); - req.on("error", (error) => { - if (error.code === "ECONNRESET") return; - reject(error); - }); - req.write(body); - req.end(); - }); -} - -async function main() { - const fakeOpencode = startFakeOpencode(); - let uvicorn = null; - let stderr = ""; - await fakeOpencode.listen(); - try { - uvicorn = spawn(".venv/bin/uvicorn", ["app.main:app", "--host", "127.0.0.1", "--port", String(OPENHUB_PORT)], { - cwd: OPENHUB_BACKEND_DIR, - env: { - ...process.env, - OPENCODE_BASE_URL: `http://localhost:${OPENCODE_PORT}`, - MNOTE_OPENHUB_TOOL_BRIDGE_AUTO: "0", - SQLITE_DB_PATH: "/tmp/openhub-fullchain-smoke.db", - NO_PROXY: "127.0.0.1,localhost", - no_proxy: "127.0.0.1,localhost", - }, - stdio: ["ignore", "ignore", "pipe"], - }); - uvicorn.stderr.on("data", (chunk) => { stderr += String(chunk); }); - await waitForHealth(`http://127.0.0.1:${OPENHUB_PORT}/api/health`); - - const tabUrl = "http://127.0.0.1:3000/documents/local-md:Inbox~2FPage.md?sourceKind=local_folder&rootUri=file:///tmp/mnote-fullchain-workspace"; - await sendOpenHub({ - question: "只回答 OK", - conversation_id: "", - agent: "build", - mnote_context: { - kind: "tab", - value: tabUrl, - tabUrl, - rootUri: "file:///tmp/mnote-fullchain-workspace", - documentId: "local-md:Inbox~2FPage.md", - relativePath: "Inbox/Page.md", - }, - }); - await wait(200); - await sendOpenHub({ question: "只回答 OK fallback", conversation_id: "", agent: "build" }); - await wait(200); - - const explicitPrompt = fakeOpencode.capturedPrompts[0]?.parts?.[0]?.text || ""; - const fallbackPrompt = fakeOpencode.capturedPrompts[1]?.parts?.[0]?.text || ""; - const result = { - ok: false, - promptCount: fakeOpencode.capturedPrompts.length, - explicitContextOk: explicitPrompt.includes("") - && explicitPrompt.includes("当前页面: http://127.0.0.1:3000/documents/") - && explicitPrompt.includes("relativePath: Inbox/Page.md"), - fallbackContextOk: fallbackPrompt.includes("") - && fallbackPrompt.includes("source: mnote_scope_fallback") - && fallbackPrompt.includes("file:///tmp/mnote-fullchain-workspace"), - explicitPromptPreview: explicitPrompt.slice(0, 700), - fallbackPromptPreview: fallbackPrompt.slice(0, 700), - }; - result.ok = result.promptCount >= 2 && result.explicitContextOk && result.fallbackContextOk; - console.log(JSON.stringify(result, null, 2)); - if (!result.ok) process.exitCode = 1; - } finally { - if (uvicorn) { - uvicorn.kill("SIGTERM"); - await wait(400); - } - await fakeOpencode.close(); - if (process.exitCode) process.stderr.write(stderr.slice(-3000)); - } -} - -main().catch((error) => { - console.error(error.stack || error.message); - process.exit(1); -}); diff --git a/recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js b/recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js deleted file mode 100644 index c547f222..00000000 --- a/recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js +++ /dev/null @@ -1,458 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); - -function parseArgs(argv) { - const args = { - fixture: "", - out: "", - dryRun: false, - manifest: "", - conflictReport: "", - rollback: "", - }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--fixture") { - args.fixture = argv[++index] || ""; - continue; - } - if (arg === "--out") { - args.out = argv[++index] || ""; - continue; - } - if (arg === "--dry-run") { - args.dryRun = true; - continue; - } - if (arg === "--manifest") { - args.manifest = argv[++index] || ""; - continue; - } - if (arg === "--conflict-report") { - args.conflictReport = argv[++index] || ""; - continue; - } - if (arg === "--rollback") { - args.rollback = argv[++index] || ""; - continue; - } - if (arg === "--help" || arg === "-h") { - console.log( - [ - "用法:node recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js --fixture --out [--dry-run] [--manifest ] [--conflict-report ]", - "回滚:node recycle/scripts/retired-convex-export-smokes-20260526/export-convex-workspace-to-local.js --rollback ", - ].join("\n"), - ); - process.exit(0); - } - throw new Error(`未知参数:${arg}`); - } - if (args.rollback) return args; - if (!args.fixture) throw new Error("缺少 --fixture"); - if (!args.out) throw new Error("缺少 --out"); - return args; -} - -function ensureDir(dir) { - fs.mkdirSync(dir, { recursive: true }); -} - -function writeUtf8(filePath, content) { - ensureDir(path.dirname(filePath)); - fs.writeFileSync(filePath, content, "utf8"); -} - -function writeJson(filePath, value) { - writeUtf8(filePath, `${JSON.stringify(value, null, 2)}\n`); -} - -function sanitizeName(name) { - return String(name || "untitled") - .trim() - .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") - .replace(/\s+/g, " ") - .replace(/\.+$/g, "") - .trim() || "untitled"; -} - -function loadFixture(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - -function buildDocumentIndex(documents) { - const byId = new Map(); - documents.forEach((doc) => { - byId.set(String(doc.id), doc); - }); - return byId; -} - -function buildMarkdownPath(doc, documentsById) { - const segments = []; - let current = doc; - while (current) { - segments.unshift(sanitizeName(current.title || current.id || "untitled")); - const parentId = current.parent_id || current.parentId || null; - current = parentId ? documentsById.get(String(parentId)) : null; - } - return path.posix.join("pages", ...segments) + ".md"; -} - -function buildAssetPath(markdownPath, fileName) { - const base = markdownPath.replace(/\.md$/i, ".assets"); - return path.posix.join(base, sanitizeName(fileName)); -} - -function findDocumentForAsset(asset, documents) { - const directId = String(asset.document_id || asset.documentId || asset.page_id || asset.pageId || "").trim(); - if (directId) return directId; - const assetId = String(asset.id || "").trim(); - if (!assetId) return ""; - const doc = documents.find((item) => { - const content = typeof item.content === "string" ? item.content : ""; - const rawText = typeof item.raw_text === "string" ? item.raw_text : ""; - const editorText = typeof item.editor_document === "string" ? item.editor_document : ""; - const tiptapText = typeof item.tiptap_document === "string" ? item.tiptap_document : ""; - return [content, rawText, editorText, tiptapText].some((text) => text.includes(assetId)); - }); - return doc ? String(doc.id) : ""; -} - -function decodeAssetContent(asset) { - if (typeof asset.contentBase64 === "string" && asset.contentBase64) { - return Buffer.from(asset.contentBase64, "base64"); - } - if (typeof asset.content === "string") { - return Buffer.from(asset.content, "utf8"); - } - return Buffer.from("", "utf8"); -} - -function rewriteAssetUrls(markdown, assetPathById) { - return String(markdown || "").replace(/\/api\/media\/sign\?assetId=([^)\s"'&#]+)/g, (_, assetId) => { - return assetPathById.get(String(assetId)) || _; - }); -} - -function serializePageOptions(doc) { - return { - wideLayout: doc.wide_layout ?? null, - useSmallText: doc.use_small_text ?? null, - showHeadingNumbers: doc.show_heading_numbers ?? null, - showToc: doc.show_toc ?? null, - showStructure: doc.show_structure ?? null, - protectEditing: doc.protect_editing ?? null, - showWordCount: doc.show_word_count ?? null, - collapseBacklinks: doc.collapse_backlinks ?? null, - pageFont: doc.page_font ?? null, - layoutDensity: doc.layout_density ?? null, - hideChildPages: doc.hide_child_pages ?? null, - showBlockRefCount: doc.show_block_ref_count ?? null, - embedDefaultBlockId: doc.embed_default_block_id ?? null, - }; -} - -function fileUriForPath(localPath) { - return `file://${path.resolve(localPath)}`; -} - -function nowId() { - return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14); -} - -function normalizeRelativePath(relativePath) { - const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); - if (!normalized || normalized.split("/").some((part) => part === "..")) { - throw new Error(`非法迁移相对路径:${relativePath}`); - } - return normalized; -} - -function pushFileOperation(operations, relativePath, content, contentEncoding = "utf8") { - operations.push({ - action: "create", - relativePath: normalizeRelativePath(relativePath), - content, - contentEncoding, - }); -} - -function buildMigrationPlan(fixture, out) { - const documents = Array.isArray(fixture.documents) ? fixture.documents : []; - const mediaAssets = Array.isArray(fixture.mediaAssets) ? fixture.mediaAssets : []; - const aiSessions = Array.isArray(fixture.aiSessions) ? fixture.aiSessions : []; - - const docsById = buildDocumentIndex(documents); - const markdownPathByDocId = new Map(); - const assetPathById = new Map(); - const pageIds = {}; - const pageOptions = {}; - const resourceIndex = { version: 1, assets: {} }; - - documents.forEach((doc) => { - const markdownPath = buildMarkdownPath(doc, docsById); - markdownPathByDocId.set(String(doc.id), markdownPath); - pageIds[markdownPath] = `local-mdid:${doc.id}`; - pageOptions[`local-mdid:${doc.id}`] = serializePageOptions(doc); - }); - - mediaAssets.forEach((asset) => { - const ownerDocId = findDocumentForAsset(asset, documents) || String(documents[0]?.id || ""); - const ownerMarkdownPath = markdownPathByDocId.get(ownerDocId) || "pages/attachments.md"; - const relativePath = buildAssetPath(ownerMarkdownPath, asset.fileName || asset.name || asset.id); - assetPathById.set(String(asset.id), relativePath); - resourceIndex.assets[String(asset.id)] = { - fileName: sanitizeName(asset.fileName || asset.name || asset.id), - relativePath, - documentId: ownerDocId || null, - }; - }); - - const operations = []; - const indexedDocuments = []; - const indexedResources = []; - - documents.forEach((doc) => { - const markdownPath = markdownPathByDocId.get(String(doc.id)); - const assetRoot = markdownPath.replace(/\.md$/i, ".assets"); - const markdownDir = path.posix.dirname(markdownPath); - const markdownBody = rewriteAssetUrls( - typeof doc.content === "string" - ? doc.content - : String(doc.raw_text || doc.editor_document || doc.tiptap_document || ""), - new Map(Array.from(assetPathById.entries()).map(([assetId, absolutePath]) => [ - assetId, - path.posix.relative(markdownDir, absolutePath), - ])), - ); - const frontmatter = [ - "---", - `title: ${String(doc.title || doc.id || "untitled")}`, - `mnote_id: ${String(doc.id)}`, - "---", - "", - ].join("\n"); - pushFileOperation(operations, markdownPath, `${frontmatter}${markdownBody}`); - indexedDocuments.push({ - documentId: `local-mdid:${doc.id}`, - title: String(doc.title || doc.id || "untitled"), - path: markdownPath, - rawText: markdownBody, - tags: [], - backlinks: [], - resourceRefs: [], - updatedAt: Date.now(), - }); - operations.push({ - action: "ensureDir", - relativePath: normalizeRelativePath(assetRoot), - }); - }); - - mediaAssets.forEach((asset) => { - const relativePath = assetPathById.get(String(asset.id)); - if (!relativePath) return; - const content = decodeAssetContent(asset).toString("base64"); - pushFileOperation(operations, relativePath, content, "base64"); - indexedResources.push({ - resourceId: String(asset.id), - resourceType: "media", - title: sanitizeName(asset.fileName || asset.name || asset.id), - path: relativePath, - updatedAt: Date.now(), - }); - }); - - aiSessions.forEach((session) => { - const sessionId = sanitizeName(session.sessionId || session.id || "session"); - const shareId = String(session.shareId || session.share_id || "").trim(); - const visibility = String(session.visibility || "").trim(); - const relativePath = shareId || visibility === "shared" - ? path.posix.join("ai-sessions", "shared", sanitizeName(shareId || "share"), `${sessionId}.jsonl`) - : path.posix.join("ai-sessions", "private", `${sessionId}.jsonl`); - const events = Array.isArray(session.events) ? session.events : []; - const lines = events.map((event) => JSON.stringify(event)).join("\n"); - pushFileOperation(operations, relativePath, lines ? `${lines}\n` : ""); - }); - - const workspaceId = fixture.workspace?.id || "exported-workspace"; - pushFileOperation(operations, ".mnote/page-ids.json", `${JSON.stringify({ version: 1, pages: pageIds }, null, 2)}\n`); - pushFileOperation(operations, ".mnote/page-options.json", `${JSON.stringify({ version: 1, pages: pageOptions }, null, 2)}\n`); - pushFileOperation(operations, ".mnote/resource-index.json", `${JSON.stringify(resourceIndex, null, 2)}\n`); - pushFileOperation( - operations, - ".mnote/workspace.json", - `${JSON.stringify( - { - workspaceId, - ownerId: fixture.workspace?.ownerId || fixture.workspace?.owner_id || "unknown", - createdAt: new Date().toISOString(), - capabilities: ["local_files", "ai_sessions", "exported_from_convex"], - }, - null, - 2, - )}\n`, - ); - pushFileOperation( - operations, - ".mnote/index/search-index.json", - `${JSON.stringify( - { - version: 1, - builtAt: Date.now(), - rootUri: fileUriForPath(out), - workspaceId, - documents: indexedDocuments, - resources: indexedResources, - }, - null, - 2, - )}\n`, - ); - - return { - workspaceId, - operations, - documentCount: documents.length, - resourceCount: mediaAssets.length, - aiSessionCount: aiSessions.length, - }; -} - -function detectConflicts(out, operations) { - return operations - .filter((operation) => operation.action === "create") - .filter((operation) => fs.existsSync(path.join(out, operation.relativePath))) - .map((operation) => ({ - code: "target_exists", - relativePath: operation.relativePath, - targetPath: path.join(out, operation.relativePath), - message: "目标文件已存在,迁移不会覆盖", - })); -} - -function manifestForPlan(out, plan, dryRun, conflicts = []) { - const migrationId = `convex-export-${nowId()}`; - return { - version: 1, - migrationId, - source: "convex_fixture", - dryRun, - out: path.resolve(out), - createdAt: new Date().toISOString(), - backupDir: path.join(path.resolve(out), ".mnote", "migration-backups", migrationId), - workspaceId: plan.workspaceId, - documentCount: plan.documentCount, - resourceCount: plan.resourceCount, - aiSessionCount: plan.aiSessionCount, - operations: plan.operations.map((operation) => ({ - action: operation.action, - relativePath: operation.relativePath, - contentEncoding: operation.contentEncoding || null, - })), - createdFiles: [], - backupFiles: [], - conflicts, - indexRefresh: { - status: dryRun ? "planned" : "pending", - path: ".mnote/index/search-index.json", - }, - }; -} - -function writeConflictReport(filePath, out, conflicts) { - if (!filePath) return; - writeJson(filePath, { - version: 1, - code: conflicts.length ? "migration_conflict" : "ok", - out: path.resolve(out), - generatedAt: new Date().toISOString(), - conflicts, - }); -} - -function applyPlan(out, plan, manifest) { - ensureDir(out); - ensureDir(manifest.backupDir); - for (const operation of plan.operations) { - const target = path.join(out, operation.relativePath); - if (operation.action === "ensureDir") { - ensureDir(target); - continue; - } - if (operation.action !== "create") continue; - ensureDir(path.dirname(target)); - const content = operation.contentEncoding === "base64" - ? Buffer.from(operation.content, "base64") - : operation.content; - fs.writeFileSync(target, content, operation.contentEncoding === "base64" ? undefined : "utf8"); - manifest.createdFiles.push(operation.relativePath); - } - manifest.indexRefresh.status = "written"; -} - -function rollbackManifest(manifestPath) { - const manifest = loadFixture(manifestPath); - const out = manifest.out; - if (!out) throw new Error("rollback manifest 缺少 out"); - const createdFiles = Array.isArray(manifest.createdFiles) ? manifest.createdFiles : []; - createdFiles - .slice() - .reverse() - .forEach((relativePath) => { - const target = path.join(out, normalizeRelativePath(relativePath)); - if (fs.existsSync(target)) fs.rmSync(target, { force: true }); - }); - const backupFiles = Array.isArray(manifest.backupFiles) ? manifest.backupFiles : []; - backupFiles.forEach((backup) => { - if (!backup || !backup.relativePath || !backup.backupPath) return; - const target = path.join(out, normalizeRelativePath(backup.relativePath)); - ensureDir(path.dirname(target)); - fs.copyFileSync(backup.backupPath, target); - }); - console.log(JSON.stringify({ ok: true, rollback: manifestPath, removed: createdFiles.length }, null, 2)); -} - -function main() { - const args = parseArgs(process.argv.slice(2)); - if (args.rollback) { - rollbackManifest(args.rollback); - return; - } - const fixture = loadFixture(args.fixture); - const plan = buildMigrationPlan(fixture, args.out); - const conflicts = detectConflicts(args.out, plan.operations); - const manifest = manifestForPlan(args.out, plan, args.dryRun, conflicts); - const manifestPath = args.manifest || path.join(args.out, ".mnote", "migration-manifest.json"); - writeConflictReport(args.conflictReport, args.out, conflicts); - if (args.dryRun) { - manifest.indexRefresh.status = "planned"; - writeJson(manifestPath, manifest); - console.log(JSON.stringify({ ok: true, dryRun: true, out: args.out, conflicts: conflicts.length }, null, 2)); - return; - } - if (conflicts.length) { - writeJson(manifestPath, manifest); - console.error(JSON.stringify({ ok: false, code: "migration_conflict", conflicts }, null, 2)); - process.exit(2); - } - applyPlan(args.out, plan, manifest); - writeJson(manifestPath, manifest); - if (path.resolve(manifestPath) !== path.resolve(path.join(args.out, ".mnote", "migration-manifest.json"))) { - writeJson(path.join(args.out, ".mnote", "migration-manifest.json"), manifest); - } - - console.log(JSON.stringify({ ok: true, out: args.out, manifest: manifestPath }, null, 2)); -} - -if (require.main === module) { - try { - main(); - } catch (error) { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); - } -} diff --git a/recycle/scripts/retired-convex-export-smokes-20260526/task444-convex-workspace-export-local-fixture-smoke.js b/recycle/scripts/retired-convex-export-smokes-20260526/task444-convex-workspace-export-local-fixture-smoke.js deleted file mode 100644 index 9b91a444..00000000 --- a/recycle/scripts/retired-convex-export-smokes-20260526/task444-convex-workspace-export-local-fixture-smoke.js +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); - -const repoRoot = path.resolve(__dirname, "..", "..", ".."); -const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-")); -const fixturePath = path.join(tmpRoot, "fixture.json"); -const outRoot = path.join(tmpRoot, "local-workspace"); - -const fixture = { - workspace: { - id: "ws_legacy_1", - name: "旧云端空间", - ownerId: "user_1", - }, - documents: [ - { - id: "doc_root", - title: "Project", - parent_id: null, - sort_order: 1, - content: "# Project\n\n![logo](/api/media/sign?assetId=asset_logo)\n", - wide_layout: true, - created_at: "2026-05-01T00:00:00.000Z", - updated_at: "2026-05-02T00:00:00.000Z", - }, - { - id: "doc_child", - title: "Child Spec", - parent_id: "doc_root", - sort_order: 1, - content: "Child body with [file](/api/media/sign?assetId=asset_pdf).\n", - created_at: "2026-05-01T00:00:00.000Z", - updated_at: "2026-05-02T00:00:00.000Z", - }, - ], - mediaAssets: [ - { - id: "asset_logo", - fileName: "logo.png", - contentBase64: Buffer.from("PNG-FIXTURE").toString("base64"), - }, - { - id: "asset_pdf", - fileName: "spec.pdf", - content: "PDF-FIXTURE", - }, - ], - aiSessions: [ - { - sessionId: "sess_1", - visibility: "private", - events: [ - { eventType: "session.created", sessionId: "sess_1", userId: "user_1" }, - { eventType: "run.completed", sessionId: "sess_1", status: "completed" }, - ], - }, - ], -}; - -fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8"); - -const result = spawnSync( - process.execPath, - [ - path.join(__dirname, "export-convex-workspace-to-local.js"), - "--fixture", - fixturePath, - "--out", - outRoot, - ], - { cwd: repoRoot, encoding: "utf8" }, -); - -assert.equal(result.status, 0, result.stderr || result.stdout); - -const projectMd = fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"); -const childMd = fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.md"), "utf8"); -const pageIds = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-ids.json"), "utf8")); -const pageOptions = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "page-options.json"), "utf8")); -const resourceIndex = JSON.parse(fs.readFileSync(path.join(outRoot, ".mnote", "resource-index.json"), "utf8")); -const sessionJsonl = fs.readFileSync(path.join(outRoot, "ai-sessions", "private", "sess_1.jsonl"), "utf8"); - -assert.match(projectMd, /mnote_id: doc_root/); -assert.match(projectMd, /!\[logo\]\(Project\.assets\/logo\.png\)/); -assert.match(childMd, /\[file\]\(Child Spec\.assets\/spec\.pdf\)/); -assert.equal(pageIds.pages["pages/Project.md"], "local-mdid:doc_root"); -assert.equal(pageIds.pages["pages/Project/Child Spec.md"], "local-mdid:doc_child"); -assert.equal(pageOptions.pages["local-mdid:doc_root"].wideLayout, true); -assert.equal(resourceIndex.assets.asset_logo.relativePath, "pages/Project.assets/logo.png"); -assert.match(sessionJsonl, /"eventType":"run.completed"/); -assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.assets", "logo.png"), "utf8"), "PNG-FIXTURE"); -assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project", "Child Spec.assets", "spec.pdf"), "utf8"), "PDF-FIXTURE"); - -fs.rmSync(tmpRoot, { recursive: true, force: true }); -console.log(JSON.stringify({ ok: true, smoke: "task444-convex-workspace-export-local-fixture" }, null, 2)); diff --git a/recycle/scripts/retired-convex-export-smokes-20260526/task455-convex-export-plan-rollback-smoke.js b/recycle/scripts/retired-convex-export-smokes-20260526/task455-convex-export-plan-rollback-smoke.js deleted file mode 100644 index 129fa628..00000000 --- a/recycle/scripts/retired-convex-export-smokes-20260526/task455-convex-export-plan-rollback-smoke.js +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); - -const repoRoot = path.resolve(__dirname, "..", "..", ".."); -const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-convex-export-plan-")); -const fixturePath = path.join(tmpRoot, "fixture.json"); -const outRoot = path.join(tmpRoot, "local-workspace"); -const manifestPath = path.join(tmpRoot, "migration-manifest.json"); -const conflictPath = path.join(tmpRoot, "conflicts.json"); - -const fixture = { - workspace: { - id: "ws_legacy_plan", - name: "迁移计划空间", - ownerId: "user_plan", - }, - documents: [ - { - id: "doc_root", - title: "Project", - parent_id: null, - content: "# Project\n\n导出正文\n", - wide_layout: true, - }, - ], - mediaAssets: [ - { - id: "asset_logo", - fileName: "logo.txt", - documentId: "doc_root", - content: "LOGO-NEW", - }, - ], - aiSessions: [], -}; - -function runExport(args, expectedStatus = 0) { - const result = spawnSync( - process.execPath, - [path.join(__dirname, "export-convex-workspace-to-local.js"), ...args], - { cwd: repoRoot, encoding: "utf8" }, - ); - assert.equal(result.status, expectedStatus, result.stderr || result.stdout); - return result; -} - -try { - fs.writeFileSync(fixturePath, JSON.stringify(fixture, null, 2), "utf8"); - - runExport([ - "--fixture", - fixturePath, - "--out", - outRoot, - "--dry-run", - "--manifest", - manifestPath, - "--conflict-report", - conflictPath, - ]); - assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "dry run 不应写入页面文件"); - const dryRunManifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - assert.equal(dryRunManifest.dryRun, true); - assert.equal(dryRunManifest.operations.some((item) => item.action === "create" && item.relativePath === "pages/Project.md"), true); - - fs.mkdirSync(path.join(outRoot, "pages"), { recursive: true }); - fs.writeFileSync(path.join(outRoot, "pages", "Project.md"), "原有内容\n", "utf8"); - const conflict = runExport([ - "--fixture", - fixturePath, - "--out", - outRoot, - "--manifest", - manifestPath, - "--conflict-report", - conflictPath, - ], 2); - assert.match(conflict.stderr + conflict.stdout, /migration_conflict/); - assert.equal(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), "原有内容\n"); - const conflictReport = JSON.parse(fs.readFileSync(conflictPath, "utf8")); - assert.equal(conflictReport.conflicts.some((item) => item.relativePath === "pages/Project.md"), true); - - fs.rmSync(outRoot, { recursive: true, force: true }); - runExport([ - "--fixture", - fixturePath, - "--out", - outRoot, - "--manifest", - manifestPath, - ]); - assert.match(fs.readFileSync(path.join(outRoot, "pages", "Project.md"), "utf8"), /导出正文/); - assert.equal(fs.existsSync(path.join(outRoot, ".mnote", "migration-manifest.json")), true); - - runExport(["--rollback", path.join(outRoot, ".mnote", "migration-manifest.json")]); - assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.md")), false, "rollback 应删除本次新增页面"); - assert.equal(fs.existsSync(path.join(outRoot, "pages", "Project.assets", "logo.txt")), false, "rollback 应删除本次新增资源"); - - console.log(JSON.stringify({ ok: true, smoke: "task455-convex-export-plan-rollback" }, null, 2)); -} finally { - fs.rmSync(tmpRoot, { recursive: true, force: true }); -} diff --git a/recycle/scripts/retired-ui-regressions/task019-document-ui-regression.js b/recycle/scripts/retired-ui-regressions/task019-document-ui-regression.js deleted file mode 100644 index d27f1487..00000000 --- a/recycle/scripts/retired-ui-regressions/task019-document-ui-regression.js +++ /dev/null @@ -1,234 +0,0 @@ -"use strict"; - -// 说明: -// - 这是 task-019 的最小真实浏览器回归脚本。 -// - 目标只覆盖文档页元信息、Sidebar、标题/正文保存主链,不扩大到 Mindmap / OnlyOffice。 -// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。 - -const { chromium } = require("playwright"); - -const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; -const REQUEST_TIMEOUT_MS = 20_000; -const UI_TIMEOUT_MS = 30_000; -const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; - -function assert(condition, message) { - if (!condition) { - throw new Error(message); - } -} - -async function requestJson(requestContext, path, init = {}) { - const response = await requestContext.fetch(`${BASE_URL}${path}`, { - ...init, - headers: - init.data !== undefined - ? { - "content-type": "application/json", - ...(init.headers || {}), - } - : { - ...(init.headers || {}), - }, - timeout: REQUEST_TIMEOUT_MS, - }); - - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = text; - } - - if (!response.ok()) { - throw new Error( - `${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, - ); - } - - const contentType = response.headers()["content-type"] || ""; - if (!contentType.includes("application/json")) { - const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200); - throw new Error( - `${path} 返回了非 JSON 内容,当前回归脚本需要可直接调用的 API 会话。` + - `如果页面被重定向到 /auth 或返回 HTML,说明前端未启用 MNOTE_DEV_AUTH=1,或当前节点没有带上有效的 Convex Auth 会话。` + - `响应片段:${snippet}`, - ); - } - - return payload; -} - -async function createTempDocument(requestContext) { - const payload = await requestJson(requestContext, "/api/documents/create", { - method: "POST", - data: { parentId: null }, - }); - - assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); - assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); - - return { - documentId: payload.id, - workspaceId: payload.workspace_id, - }; -} - -async function purgeTempDocument(requestContext, documentId) { - await requestJson(requestContext, "/api/documents/purge", { - method: "POST", - data: { documentId }, - }); -} - -async function getViewerIdentity(requestContext) { - const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" }); - assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId"); - return payload; -} - -async function ensureAuthenticated(page, requestContext) { - await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS }); - - if (page.url().includes("/auth")) { - const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); - await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => !url.toString().includes("/auth"), { - timeout: UI_TIMEOUT_MS, - }); - } - - return await getViewerIdentity(requestContext); -} - -async function runBrowserRegression(page, target) { - const uniqueSuffix = Date.now().toString(); - const nextTitle = `task019-ui-${uniqueSuffix}`; - const nextBody = `task019 正文保存回归 ${uniqueSuffix}`; - const documentUrl = `${BASE_URL}/documents/${target.documentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`; - await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - - const sidebarPanel = page.getByText("页面树"); - const privateSection = page.getByText("私有 / 我的页面"); - const titleInput = page.getByLabel("页面标题"); - const editorSurface = page.locator(".wolai-editor [contenteditable=\"true\"]").first(); - const saveIndicator = page.locator("text=已保存"); - - await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await editorSurface.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - await titleInput.fill(nextTitle); - const titleSaveResponse = page.waitForResponse( - (response) => - response.url().includes("/api/documents/title") && - response.request().method() === "POST" && - response.status() === 200, - { timeout: UI_TIMEOUT_MS }, - ); - await titleInput.evaluate((node) => { - node.blur(); - }); - await titleSaveResponse; - - const saveResponse = page.waitForResponse( - (response) => - response.url().includes("/api/documents/save") && - response.request().method() === "POST" && - response.status() === 200 && - (response.request().postData() || "").includes(nextBody), - { timeout: UI_TIMEOUT_MS }, - ); - await editorSurface.click({ timeout: UI_TIMEOUT_MS }); - await editorSurface.fill(nextBody, { timeout: UI_TIMEOUT_MS }); - await saveResponse; - await saveIndicator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await privateSection.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await titleInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await page.locator(`text=${nextBody}`).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - const persistedTitle = await titleInput.inputValue(); - assert(persistedTitle === nextTitle, `标题刷新后不一致:期望 ${nextTitle},实际 ${persistedTitle}`); - const editorText = await page.locator(".wolai-editor").innerText({ timeout: UI_TIMEOUT_MS }); - assert(editorText.includes(nextBody), "正文刷新后未保留刚写入的内容"); - - return { - documentUrl, - nextTitle, - nextBody, - }; -} - -async function main() { - const health = await fetch(`${BASE_URL}/`, { - method: "HEAD", - redirect: "manual", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - assert( - [200, 307, 308].includes(health.status), - `首页探活失败:收到状态码 ${health.status}`, - ); - - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ - viewport: { width: 1440, height: 960 }, - }); - const page = await context.newPage(); - let tempDocument = null; - let regressionResult = null; - let caughtError = null; - - try { - await ensureAuthenticated(page, context.request); - tempDocument = await createTempDocument(context.request); - regressionResult = await runBrowserRegression(page, tempDocument); - console.log( - JSON.stringify( - { - ok: true, - workspaceId: tempDocument.workspaceId, - documentId: tempDocument.documentId, - ...regressionResult, - }, - null, - 2, - ), - ); - } catch (error) { - caughtError = error; - } finally { - if (tempDocument?.documentId) { - try { - await purgeTempDocument(context.request, tempDocument.documentId); - } catch (cleanupError) { - if (!caughtError) { - caughtError = cleanupError; - } else { - console.error( - `清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`, - ); - } - } - } - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } - - if (caughtError) { - throw caughtError; - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/recycle/scripts/retired-ui-regressions/task021-mindmap-ui-regression.js b/recycle/scripts/retired-ui-regressions/task021-mindmap-ui-regression.js deleted file mode 100644 index 7d1adb46..00000000 --- a/recycle/scripts/retired-ui-regressions/task021-mindmap-ui-regression.js +++ /dev/null @@ -1,492 +0,0 @@ -"use strict"; - -// 说明: -// - 这是 task-021 的最小真实浏览器回归脚本。 -// - 目标覆盖 Mindmap 全屏页、节点新增/删除、保存链与 requestId/traceId 元信息同步。 -// - 脚本会创建临时页面和临时导图,回归结束后清理,避免污染现有数据。 - -const { chromium } = require("playwright"); - -const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; -const REQUEST_TIMEOUT_MS = 20_000; -const UI_TIMEOUT_MS = 30_000; -const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -function assert(condition, message) { - if (!condition) { - throw new Error(message); - } -} - -async function requestJson(requestContext, path, init = {}) { - const response = await requestContext.fetch(`${BASE_URL}${path}`, { - ...init, - headers: - init.data !== undefined - ? { - "content-type": "application/json", - ...(init.headers || {}), - } - : { - ...(init.headers || {}), - }, - timeout: REQUEST_TIMEOUT_MS, - }); - - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = text; - } - - if (!response.ok()) { - throw new Error( - `${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, - ); - } - - return payload; -} - -async function createTempDocument(requestContext) { - const payload = await requestJson(requestContext, "/api/documents/create", { - method: "POST", - data: { parentId: null }, - }); - - assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); - assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); - - return { - documentId: payload.id, - workspaceId: payload.workspace_id, - }; -} - -async function createTempMindmap(requestContext, documentId, mindmapId) { - await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, { - method: "POST", - data: { - createOnly: true, - data: { - data: { text: "中心主题" }, - children: [], - }, - }, - }); -} - -async function cleanupTempMindmap(requestContext, documentId, mindmapId) { - try { - await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`, { - method: "DELETE", - }); - } catch { - // 忽略清理失败,继续尝试 purge 文档。 - } -} - -async function purgeTempDocument(requestContext, documentId) { - await requestJson(requestContext, "/api/documents/purge", { - method: "POST", - data: { documentId }, - }); -} - -async function getViewerIdentity(requestContext) { - const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" }); - assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId"); - return payload; -} - -async function ensureAuthenticated(page, requestContext) { - await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS }); - - if (page.url().includes("/auth")) { - const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); - await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => !url.toString().includes("/auth"), { - timeout: UI_TIMEOUT_MS, - }); - } - - return await getViewerIdentity(requestContext); -} - -async function waitForMindmapInstance(page, mindmapId) { - await page.waitForFunction( - (id) => Boolean(window.__mindmapInstancesById?.[id] || window.__mindmapInstance), - mindmapId, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function waitForMindmapReady(page, mindmapId) { - await page.waitForFunction( - (id) => { - const instance = window.__mindmapInstancesById?.[id] || window.__mindmapInstance; - const persist = window.__mindmapPersistById?.[id]; - const fullscreen = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); - const requestId = fullscreen?.getAttribute("data-request-id"); - const traceId = fullscreen?.getAttribute("data-trace-id"); - return Boolean(instance && persist && requestId && traceId); - }, - mindmapId, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function readMindmapMetaAttrs(page) { - const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]"); - return { - documentId: await fullscreen.getAttribute("data-document-id"), - pageId: await fullscreen.getAttribute("data-page-id"), - attachmentId: await fullscreen.getAttribute("data-attachment-id"), - mindmapId: await fullscreen.getAttribute("data-mindmap-id"), - workspaceId: await fullscreen.getAttribute("data-workspace-id"), - requestId: await fullscreen.getAttribute("data-request-id"), - traceId: await fullscreen.getAttribute("data-trace-id"), - }; -} - -async function waitForMetaAttrs(page, meta) { - await page.waitForFunction( - ({ requestId, traceId }) => { - const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); - if (!el) return false; - return ( - el.getAttribute("data-request-id") === requestId && - el.getAttribute("data-trace-id") === traceId - ); - }, - { - requestId: meta.requestId, - traceId: meta.traceId, - }, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function waitForMetaMutation(page, previousMeta) { - await page.waitForFunction( - ({ requestId, traceId }) => { - const el = document.querySelector("[data-testid=\"mindmap-fullscreen\"]"); - if (!el) return false; - const nextRequestId = el.getAttribute("data-request-id"); - const nextTraceId = el.getAttribute("data-trace-id"); - return Boolean( - nextRequestId && - nextTraceId && - nextRequestId !== requestId && - nextTraceId !== traceId, - ); - }, - { - requestId: previousMeta.requestId, - traceId: previousMeta.traceId, - }, - { timeout: UI_TIMEOUT_MS }, - ); - return await readMindmapMetaAttrs(page); -} - -async function waitForMindmapState(requestContext, documentId, mindmapId, check, description) { - const deadline = Date.now() + UI_TIMEOUT_MS; - let lastPayload = null; - while (Date.now() < deadline) { - lastPayload = await requestJson(requestContext, `/api/mindmap/${documentId}/${mindmapId}`); - if (check(lastPayload)) { - return lastPayload; - } - await sleep(500); - } - throw new Error(`${description} 超时:${JSON.stringify(lastPayload)}`); -} - -function assertRouteMeta(meta, expected) { - assert(meta && typeof meta.requestId === "string" && meta.requestId, "缺少 meta.requestId"); - assert(meta && typeof meta.traceId === "string" && meta.traceId, "缺少 meta.traceId"); - assert(meta.documentId === expected.documentId, `documentId 不一致:${meta.documentId}`); - assert(meta.pageId === expected.documentId, `pageId 不一致:${meta.pageId}`); - assert(meta.mindmapId === expected.mindmapId, `mindmapId 不一致:${meta.mindmapId}`); - assert(meta.attachmentId === expected.mindmapId, `attachmentId 不一致:${meta.attachmentId}`); - assert(meta.workspaceId === expected.workspaceId, `workspaceId 不一致:${meta.workspaceId}`); -} - -async function persistInsertAndRename(page, mindmapId) { - return page.evaluate( - ({ currentMindmapId }) => { - const instance = - window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; - if (!instance) { - throw new Error("未找到 mindmap 实例"); - } - - const renderer = instance.renderer; - const root = renderer?.root ?? renderer?.renderTree?._node; - if (!root) { - throw new Error("未找到根节点"); - } - - renderer?.clearActiveNodeList?.(); - renderer?.addNodeToActiveList?.(root, true); - renderer.lastActiveNodeList = [root]; - renderer?.emitNodeActiveEvent?.(root); - instance.execCommand?.("SET_NODE_ACTIVE", root, true); - instance.execCommand?.("INSERT_CHILD_NODE", false, [root]); - - const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; - if (!snapshot?.root?.children?.[0]?.data) { - throw new Error("插入子节点后未拿到快照"); - } - - const persist = window.__mindmapPersistById?.[currentMindmapId]; - if (!persist) { - throw new Error("未找到 mindmap 持久化回调"); - } - persist(snapshot); - return { - childText: String(snapshot.root.children[0].data.text ?? ""), - childCount: snapshot.root.children.length, - }; - }, - { - currentMindmapId: mindmapId, - }, - ); -} - -async function persistDeleteChild(page, mindmapId, childUid) { - return page.evaluate(async ({ currentMindmapId, currentChildUid }) => { - const instance = - window.__mindmapInstancesById?.[currentMindmapId] ?? window.__mindmapInstance; - if (!instance) { - throw new Error("未找到 mindmap 实例"); - } - const renderer = instance.renderer; - const child = - typeof renderer?.findNodeByUid === "function" - ? renderer.findNodeByUid(currentChildUid) - : null; - if (!child) { - throw new Error("删除子节点时未找到目标节点"); - } - renderer?.clearActiveNodeList?.(); - renderer?.addNodeToActiveList?.(child, true); - renderer.lastActiveNodeList = [child]; - renderer?.emitNodeActiveEvent?.(child); - instance.execCommand?.("SET_NODE_ACTIVE", child, true); - await new Promise((resolve) => window.setTimeout(resolve, 0)); - instance.execCommand?.("REMOVE_NODE"); - await new Promise((resolve) => window.setTimeout(resolve, 0)); - const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; - const root = snapshot?.root ?? snapshot; - return { - childCount: - root && typeof root === "object" && Array.isArray(root.children) - ? root.children.length - : -1, - }; - }, { currentMindmapId: mindmapId, currentChildUid: childUid }); -} - -async function openOutlinePanel(page) { - const outlineButton = page.getByRole("button", { name: "大纲" }); - await outlineButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await outlineButton.click(); -} - -async function runBrowserRegression(page, requestContext, target) { - const uniqueSuffix = Date.now().toString(); - const mindmapId = `task021-${uniqueSuffix}`; - const defaultChildText = "二级节点"; - - try { - await createTempMindmap(requestContext, target.documentId, mindmapId); - - const mindmapUrl = `${BASE_URL}/mindmap/${target.documentId}/${mindmapId}`; - await page.goto(mindmapUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - - const fullscreen = page.locator("[data-testid=\"mindmap-fullscreen\"]"); - const canvas = page.locator("[data-testid=\"mindmap-canvas\"]"); - const rootText = page.getByText("中心主题").first(); - - await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await rootText.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await waitForMindmapInstance(page, mindmapId); - await waitForMindmapReady(page, mindmapId); - - const initialMetaAttrs = await readMindmapMetaAttrs(page); - assert(initialMetaAttrs.documentId === target.documentId, "页面 data-document-id 不正确"); - assert(initialMetaAttrs.pageId === target.documentId, "页面 data-page-id 不正确"); - assert(initialMetaAttrs.mindmapId === mindmapId, "页面 data-mindmap-id 不正确"); - assert(initialMetaAttrs.attachmentId === mindmapId, "页面 data-attachment-id 不正确"); - assert(initialMetaAttrs.workspaceId === target.workspaceId, "页面 data-workspace-id 不正确"); - - const insertMutation = await persistInsertAndRename(page, mindmapId); - assert(insertMutation.childCount === 1, `插入子节点后数量异常:${insertMutation.childCount}`); - assert(insertMutation.childText, "插入子节点后名称为空"); - - const insertSaveMeta = await waitForMetaMutation(page, initialMetaAttrs); - assertRouteMeta(insertSaveMeta, { - documentId: target.documentId, - mindmapId, - workspaceId: target.workspaceId, - }); - await waitForMindmapState( - requestContext, - target.documentId, - mindmapId, - (payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 1, - "插入子节点后后端回查", - ); - - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await waitForMindmapInstance(page, mindmapId); - await waitForMindmapReady(page, mindmapId); - const beforeDeleteMeta = await readMindmapMetaAttrs(page); - const insertSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`); - assert( - Array.isArray(insertSavedMindmap.data?.children) && - insertSavedMindmap.data.children.length === 1, - "刷新后导图子节点数量不正确", - ); - assert( - typeof insertSavedMindmap.data.children[0]?.data?.text === "string" && - insertSavedMindmap.data.children[0].data.text.trim(), - "刷新后导图子节点名称为空", - ); - const persistedChildText = - String(insertSavedMindmap.data.children[0]?.data?.text ?? "") - .replace(/<[^>]+>/g, "") - .trim() || defaultChildText; - const persistedChildUid = String(insertSavedMindmap.data.children[0]?.data?.uid ?? ""); - assert(persistedChildUid, "刷新后导图子节点缺少 uid"); - await openOutlinePanel(page); - await page.getByText(persistedChildText).first().waitFor({ - state: "visible", - timeout: UI_TIMEOUT_MS, - }); - - const deleteMutation = await persistDeleteChild(page, mindmapId, persistedChildUid); - assert(deleteMutation.childCount === 0, `删除子节点后数量异常:${deleteMutation.childCount}`); - - const deleteSaveMeta = await waitForMetaMutation(page, beforeDeleteMeta); - assertRouteMeta(deleteSaveMeta, { - documentId: target.documentId, - mindmapId, - workspaceId: target.workspaceId, - }); - await waitForMindmapState( - requestContext, - target.documentId, - mindmapId, - (payload) => Array.isArray(payload?.data?.children) && payload.data.children.length === 0, - "删除子节点后后端回查", - ); - - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await fullscreen.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await canvas.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await waitForMindmapInstance(page, mindmapId); - await waitForMindmapReady(page, mindmapId); - await openOutlinePanel(page); - - await page.getByText("中心主题").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - const deleteSavedMindmap = await requestJson(requestContext, `/api/mindmap/${target.documentId}/${mindmapId}`); - assert( - Array.isArray(deleteSavedMindmap.data?.children) && - deleteSavedMindmap.data.children.length === 0, - "删除子节点后后端仍保留子节点", - ); - const canvasText = await canvas.innerText({ timeout: UI_TIMEOUT_MS }); - assert(!canvasText.includes(persistedChildText), "删除子节点后画布仍残留旧节点文本"); - - return { - mindmapUrl, - mindmapId, - childText: persistedChildText, - initialMetaAttrs, - insertMeta: insertSaveMeta, - deleteMeta: deleteSaveMeta, - }; - } finally { - await cleanupTempMindmap(requestContext, target.documentId, mindmapId); - } -} - -async function main() { - const health = await fetch(`${BASE_URL}/`, { - method: "HEAD", - redirect: "manual", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - assert( - [200, 307, 308].includes(health.status), - `首页探活失败:收到状态码 ${health.status}`, - ); - - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ - viewport: { width: 1440, height: 960 }, - }); - const page = await context.newPage(); - let tempDocument = null; - let regressionResult = null; - let caughtError = null; - - try { - await ensureAuthenticated(page, context.request); - tempDocument = await createTempDocument(context.request); - regressionResult = await runBrowserRegression(page, context.request, tempDocument); - console.log( - JSON.stringify( - { - ok: true, - workspaceId: tempDocument.workspaceId, - documentId: tempDocument.documentId, - ...regressionResult, - }, - null, - 2, - ), - ); - } catch (error) { - caughtError = error; - } finally { - if (tempDocument?.documentId) { - try { - await purgeTempDocument(context.request, tempDocument.documentId); - } catch (cleanupError) { - if (!caughtError) { - caughtError = cleanupError; - } else { - console.error( - `清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`, - ); - } - } - } - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } - - if (caughtError) { - throw caughtError; - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/recycle/scripts/retired-ui-regressions/task022-onlyoffice-ui-regression.js b/recycle/scripts/retired-ui-regressions/task022-onlyoffice-ui-regression.js deleted file mode 100644 index c3dd81fb..00000000 --- a/recycle/scripts/retired-ui-regressions/task022-onlyoffice-ui-regression.js +++ /dev/null @@ -1,414 +0,0 @@ -"use strict"; - -// 说明: -// - 这是 task-022 的最小真实浏览器回归脚本。 -// - 目标覆盖 OnlyOffice 页面打开、插件桥接插入文本、forcesave 按钮、callback 写回闭环。 -// - 脚本会创建临时页面并上传临时 docx,回归结束后 purge 页面,避免污染现有数据。 - -const fs = require("node:fs"); -const { chromium } = require("playwright"); - -const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000"; -const REQUEST_TIMEOUT_MS = 20_000; -const UI_TIMEOUT_MS = 120_000; -const CALLBACK_TIMEOUT_MS = 90_000; -const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX || "/tmp/mnote-onlyoffice-probe/probe.docx"; -const ONLYOFFICE_PLUGIN_CHANNEL = "mnote_onlyoffice_agent_tools_v1"; -const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; -const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; - -function assert(condition, message) { - if (!condition) { - throw new Error(message); - } -} - -async function requestPayload(requestContext, path, init = {}) { - const headers = - init.multipart || init.form - ? { ...(init.headers || {}) } - : init.data !== undefined - ? { - "content-type": "application/json", - ...(init.headers || {}), - } - : { ...(init.headers || {}) }; - - const response = await requestContext.fetch(`${BASE_URL}${path}`, { - ...init, - headers, - timeout: REQUEST_TIMEOUT_MS, - }); - - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = text; - } - - if (!response.ok()) { - throw new Error( - `${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, - ); - } - - return payload; -} - -async function createTempDocument(requestContext) { - const payload = await requestPayload(requestContext, "/api/documents/create", { - method: "POST", - data: { parentId: null }, - }); - - assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id"); - assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id"); - - return { - documentId: payload.id, - workspaceId: payload.workspace_id, - }; -} - -async function purgeTempDocument(requestContext, documentId) { - await requestPayload(requestContext, "/api/documents/purge", { - method: "POST", - data: { documentId }, - }); -} - -async function getViewerIdentity(requestContext) { - const payload = await requestPayload(requestContext, "/api/auth/whoami", { method: "GET" }); - assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId"); - return payload; -} - -async function uploadProbeDocx(requestContext, target) { - assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测文件:${PROBE_DOCX_PATH}`); - const buffer = fs.readFileSync(PROBE_DOCX_PATH); - const payload = await requestPayload(requestContext, "/api/media/upload", { - method: "POST", - multipart: { - file: { - name: "task022-probe.docx", - mimeType: DOCX_MIME, - buffer, - }, - workspaceId: target.workspaceId, - documentId: target.documentId, - }, - }); - - assert(payload && payload.asset && typeof payload.asset.id === "string", "上传探测 docx 失败:缺少 asset.id"); - return payload.asset; -} - -async function getSignedAsset(requestContext, assetId) { - const payload = await requestPayload(requestContext, `/api/media/sign?assetId=${encodeURIComponent(assetId)}`, { - method: "GET", - }); - assert(payload && typeof payload.signedUrl === "string" && payload.signedUrl, "缺少 signedUrl"); - return payload; -} - -async function ensureAuthenticated(page, requestContext) { - await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS }); - - if (page.url().includes("/auth")) { - const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); - await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); - await page.waitForURL((url) => !url.toString().includes("/auth"), { - timeout: UI_TIMEOUT_MS, - }); - } - - return await getViewerIdentity(requestContext); -} - -async function installPluginBridge(page) { - await page.addInitScript( - ({ channel }) => { - const state = { - channel, - ready: false, - origin: "*", - target: null, - pending: new Map(), - }; - - window.__TASK022_ONLYOFFICE_PLUGIN__ = state; - window.addEventListener("message", (event) => { - const data = event?.data; - if (!data || typeof data !== "object") return; - if (data.channel !== channel) return; - - if (data.type === "ready") { - state.ready = true; - state.origin = String(event.origin || "*"); - state.target = - event.source && typeof event.source.postMessage === "function" ? event.source : null; - return; - } - - if (data.type === "result") { - const callId = String(data.callId || "").trim(); - if (!callId) return; - const pending = state.pending.get(callId); - if (!pending) return; - state.pending.delete(callId); - window.clearTimeout(pending.timeoutId); - if (data.ok) { - pending.resolve(data.result ?? null); - } else { - pending.reject(new Error(String(data.error || "插件执行失败"))); - } - } - }); - }, - { channel: ONLYOFFICE_PLUGIN_CHANNEL }, - ); -} - -async function waitForOnlyOfficeReady(page) { - await page.waitForFunction(() => window.__MNOTE_ONLYOFFICE_READY__ === true, { - timeout: UI_TIMEOUT_MS, - }); - await page.waitForFunction( - () => { - const root = document.getElementById("onlyoffice-frame"); - if (root && root.querySelector("iframe,canvas")) return true; - return Boolean(document.querySelector("iframe,canvas")); - }, - { timeout: UI_TIMEOUT_MS }, - ); -} - -function getEditorIframe(page) { - return page.locator('iframe[src*="/documenteditor/main/index.html"]').first(); -} - -async function waitForPluginReady(page) { - await page.waitForFunction( - () => Boolean(window.__TASK022_ONLYOFFICE_PLUGIN__?.ready && window.__TASK022_ONLYOFFICE_PLUGIN__?.target), - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function callOnlyOfficePlugin(page, tool, args) { - return page.evaluate( - async ({ channel, toolName, toolArgs }) => { - const state = window.__TASK022_ONLYOFFICE_PLUGIN__; - if (!state || !state.ready || !state.target) { - throw new Error("OnlyOffice 插件桥未就绪"); - } - - const callId = `task022-${Date.now()}-${Math.random().toString(16).slice(2)}`; - return await new Promise((resolve, reject) => { - const timeoutId = window.setTimeout(() => { - state.pending.delete(callId); - reject(new Error(`插件调用超时: ${toolName}`)); - }, 60_000); - - state.pending.set(callId, { resolve, reject, timeoutId }); - state.target.postMessage( - { - channel, - type: "call", - callId, - tool: toolName, - args: toolArgs, - }, - state.origin || "*", - ); - }); - }, - { - channel: ONLYOFFICE_PLUGIN_CHANNEL, - toolName: tool, - toolArgs: args, - }, - ); -} - -async function getOnlyOfficeDebug(page) { - return page.evaluate(() => ({ - ready: Boolean(window.__MNOTE_ONLYOFFICE_READY__), - debug: window.__MNOTE_ONLYOFFICE_DEBUG__ ?? null, - errlog: window.__MNOTE_ONLYOFFICE_ERRLOG__ ?? [], - })); -} - -async function waitForStorageIdChange(requestContext, assetId, previousStorageId) { - const startedAt = Date.now(); - while (Date.now() - startedAt < CALLBACK_TIMEOUT_MS) { - const payload = await getSignedAsset(requestContext, assetId); - const nextStorageId = String(payload.asset?.storage_id || "").trim(); - if (nextStorageId && nextStorageId !== previousStorageId) { - return payload; - } - await new Promise((resolve) => setTimeout(resolve, 2_000)); - } - throw new Error(`等待 callback 写回超时:storage_id 仍为 ${previousStorageId || ""}`); -} - -async function runBrowserRegression(page, requestContext, viewer, target) { - const asset = await uploadProbeDocx(requestContext, target); - const initialSigned = await getSignedAsset(requestContext, asset.id); - const initialStorageId = String(initialSigned.asset?.storage_id || "").trim(); - assert(initialStorageId, "初始 storage_id 为空"); - - const uniqueSuffix = Date.now().toString(); - const insertedText = ` task022-onlyoffice-${uniqueSuffix} `; - - await installPluginBridge(page); - - try { - const pageUrl = new URL("/onlyoffice", BASE_URL); - pageUrl.searchParams.set("fileUrl", String(initialSigned.signedUrl)); - pageUrl.searchParams.set("fileName", "task022-probe.docx"); - pageUrl.searchParams.set("fileType", "docx"); - pageUrl.searchParams.set("mode", "edit"); - pageUrl.searchParams.set("assetId", asset.id); - pageUrl.searchParams.set("documentId", target.documentId); - pageUrl.searchParams.set("userId", viewer.userId); - pageUrl.searchParams.set("channel", "web"); - - await page.goto(pageUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await page.getByText("ONLYOFFICE 加载失败").waitFor({ state: "hidden", timeout: 5_000 }).catch(() => null); - - await waitForOnlyOfficeReady(page); - await waitForPluginReady(page); - - const editorIframe = getEditorIframe(page); - await editorIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await editorIframe.click({ position: { x: 160, y: 120 }, timeout: UI_TIMEOUT_MS }); - - const initialDebug = await getOnlyOfficeDebug(page); - assert(initialDebug.ready === true, "OnlyOffice ready 标记未就绪"); - assert(initialDebug.debug && initialDebug.debug.assetId === asset.id, "OnlyOffice debug.assetId 不正确"); - assert(initialDebug.debug && initialDebug.debug.documentId === target.documentId, "OnlyOffice debug.documentId 不正确"); - assert(initialDebug.debug && initialDebug.debug.baseUrl === "/onlyoffice-server", `OnlyOffice baseUrl 异常:${JSON.stringify(initialDebug.debug)}`); - assert( - initialDebug.debug && typeof initialDebug.debug.resolvedFileUrl === "string" && initialDebug.debug.resolvedFileUrl.includes("/api/onlyoffice/proxy"), - `OnlyOffice resolvedFileUrl 未走 proxy:${JSON.stringify(initialDebug.debug)}`, - ); - assert(initialDebug.debug && typeof initialDebug.debug.docKey === "string" && initialDebug.debug.docKey, "OnlyOffice debug.docKey 为空"); - - const pluginResult = await callOnlyOfficePlugin(page, "oo_insert_text", { text: insertedText }); - assert(pluginResult && pluginResult.ok === true, `插件插入文本失败:${JSON.stringify(pluginResult)}`); - - await page.waitForTimeout(2_000); - - const forceSaveResponsePromise = page.waitForResponse( - (response) => - response.url().includes(`/api/onlyoffice/forcesave?assetId=${encodeURIComponent(asset.id)}`) && - response.request().method() === "POST" && - response.status() === 200, - { timeout: UI_TIMEOUT_MS }, - ); - - const forceSaveButton = page.getByRole("button", { name: "同步保存" }); - await forceSaveButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await forceSaveButton.click({ timeout: UI_TIMEOUT_MS }); - const forceSaveResponse = await forceSaveResponsePromise; - const forceSavePayload = await forceSaveResponse.json(); - assert(forceSavePayload && forceSavePayload.ok === true, `forcesave 返回异常:${JSON.stringify(forceSavePayload)}`); - - await page.getByText("已触发同步保存").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - const updatedSigned = await waitForStorageIdChange(requestContext, asset.id, initialStorageId); - - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await waitForOnlyOfficeReady(page); - await waitForPluginReady(page); - - const reloadDebug = await getOnlyOfficeDebug(page); - assert(reloadDebug.ready === true, "刷新后 OnlyOffice ready 标记未就绪"); - assert( - String(updatedSigned.asset?.storage_id || "").trim() !== initialStorageId, - "callback 写回后 storage_id 未发生变化", - ); - - return { - pageUrl: pageUrl.toString(), - assetId: asset.id, - initialStorageId, - updatedStorageId: String(updatedSigned.asset?.storage_id || "").trim(), - insertedText, - debug: reloadDebug.debug, - errlog: reloadDebug.errlog, - }; - } catch (error) { - const debug = await getOnlyOfficeDebug(page).catch(() => null); - if (debug) { - console.error(JSON.stringify({ onlyofficeDebug: debug }, null, 2)); - } - throw error; - } -} - -async function main() { - const health = await fetch(`${BASE_URL}/`, { - method: "HEAD", - redirect: "manual", - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - assert([200, 307, 308].includes(health.status), `首页探活失败:收到状态码 ${health.status}`); - - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ - viewport: { width: 1440, height: 960 }, - }); - const page = await context.newPage(); - let tempDocument = null; - let caughtError = null; - - try { - const viewer = await ensureAuthenticated(page, context.request); - tempDocument = await createTempDocument(context.request); - const result = await runBrowserRegression(page, context.request, viewer, tempDocument); - console.log( - JSON.stringify( - { - ok: true, - workspaceId: tempDocument.workspaceId, - documentId: tempDocument.documentId, - ...result, - }, - null, - 2, - ), - ); - } catch (error) { - caughtError = error; - } finally { - if (tempDocument?.documentId) { - try { - await purgeTempDocument(context.request, tempDocument.documentId); - } catch (cleanupError) { - if (!caughtError) { - caughtError = cleanupError; - } else { - console.error( - `清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`, - ); - } - } - } - - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } - - if (caughtError) { - throw caughtError; - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1529d88b..61182c57 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -52,6 +52,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -143,6 +158,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -431,6 +458,27 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -635,6 +683,24 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "comrak" version = "0.52.0" @@ -4010,12 +4076,16 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "async-compression", "bitflags 2.13.0", "bytes", + "futures-core", "futures-util", "http 1.4.2", "http-body 1.0.1", "pin-project-lite", + "tokio", + "tokio-util", "tower 0.5.3", "tower-layer", "tower-service", diff --git a/rust/crates/mnote-tiptap-island/README.md b/rust/crates/mnote-tiptap-island/README.md new file mode 100644 index 00000000..3267dc39 --- /dev/null +++ b/rust/crates/mnote-tiptap-island/README.md @@ -0,0 +1,23 @@ +# mnote-tiptap-island(产品名) + +> 状态:Phase 3 薄 alias / 迁移计划(架构收口 21) +> +> 当前实现路径仍为:`rust/spikes/leptos-tiptap-spike/` +> 稳定协议:`mnote.leptos_tiptap.bridge.v1` +> 稳定事件前缀:`mnote:tiptap-island:*`(与历史 `mnote:leptos-tiptap-spike:*` 双发兼容) + +## 为何尚未物理迁出 spikes/ + +WASM island 的 Trunk/生成资产路径、大量 smoke 与 `mnote-web` asset route 仍绑定 `leptos-tiptap-spike` 目录名与 `mnote-leptos-tiptap-spike-island.*` 文件名。一次物理搬家会打断热更新与回归基线。 + +## 迁移完成条件 + +1. 构建脚本与 `web_shell` asset route 改为 `mnote-tiptap-island` 命名。 +2. 浏览器 host 默认监听 `mnote:tiptap-island:*`;spike 前缀仅兼容。 +3. 物理目录迁到本 crate 后删除本 README 的「薄 alias」说明,并更新 `AGENTS.md`。 + +## 当前可安全使用的产品表述 + +- 产品名:`mnote-tiptap-island` +- 实现目录(过渡):`rust/spikes/leptos-tiptap-spike` +- 宿主适配:`rust/crates/mnote-web/browser/document-*-runtime.js` diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index d6645717..beb915be 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -23,7 +23,7 @@ serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-tungstenite = "0.29" -tower-http = { version = "0.6", features = ["trace"] } +tower-http = { version = "0.6", features = ["trace", "compression-gzip", "compression-br"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt"] } tower = "0.5" diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index aeb1b9a7..d09276ee 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -28,7 +28,10 @@ import { (() => { const PANES_BOOTSTRAP_ID = '__MNOTE_DOCUMENT_PANES_BOOTSTRAP__'; const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]'; + // 历史 spike 前缀仍是 island runtime 的默认派发;稳定前缀 `mnote:tiptap-island` + // 由 island 双发(架构收口 21 Phase 3),host 优先监听 spike 事件保持兼容。 const EVENT_PREFIX = 'mnote:leptos-tiptap-spike'; + const STABLE_EVENT_PREFIX = 'mnote:tiptap-island'; const CHANGE_EVENT = `${EVENT_PREFIX}:change`; const SAVE_EVENT = `${EVENT_PREFIX}:save-request`; const READY_EVENT = `${EVENT_PREFIX}:ready`; @@ -50,6 +53,72 @@ import { return url.toString(); }; + const refreshBreadcrumb = (documentId, title, workspaceId) => { + const host = document.querySelector('[data-breadcrumb-pages="true"]'); + if (!(host instanceof HTMLElement)) return; + const rows = Array.from(document.querySelectorAll( + '.wolai-page-row[data-node-id], .tree-row[data-shell-mode="page"][data-node-id]', + )); + const byId = new Map(); + rows.forEach((row) => { + const id = String(row.getAttribute('data-node-id') || row.getAttribute('data-document-id') || '').trim(); + if (!id || byId.has(id)) return; + const titleNode = row.querySelector('.wolai-row-title, .tree-link-title'); + byId.set(id, { + id, + title: String(titleNode?.textContent || '').trim() || '无标题', + parentId: String(row.getAttribute('data-parent-id') || '').trim(), + href: row instanceof HTMLAnchorElement ? row.href : '', + }); + }); + const chain = []; + const seen = new Set(); + let cursor = String(documentId || '').trim(); + while (cursor && !seen.has(cursor) && chain.length < 64) { + const item = byId.get(cursor); + if (!item) break; + seen.add(cursor); + chain.push(item); + cursor = item.parentId; + } + chain.reverse(); + if (!chain.length) return; + const query = new URLSearchParams(window.location.search); + if (workspaceId) query.set('workspaceId', workspaceId); + const children = []; + chain.forEach((item, index) => { + if (index > 0) { + const separator = document.createElement('span'); + separator.className = 'wolai-breadcrumb-separator'; + separator.setAttribute('aria-hidden', 'true'); + separator.textContent = '›'; + children.push(separator); + } + if (index === chain.length - 1) { + const current = document.createElement('span'); + current.className = 'wolai-breadcrumb-current'; + current.dataset.breadcrumbDocumentId = item.id; + const icon = document.createElement('span'); + icon.className = 'material-symbols-outlined wolai-home-icon'; + icon.dataset.icon = 'home'; + icon.setAttribute('aria-hidden', 'true'); + const text = document.createElement('span'); + text.dataset.pageTitleCurrent = 'true'; + text.textContent = title || item.title; + current.append(icon, text); + children.push(current); + } else { + const link = document.createElement('a'); + link.className = 'wolai-breadcrumb-link'; + link.dataset.breadcrumbDocumentId = item.id; + link.href = item.href || `/documents/${encodeURIComponent(item.id)}?${query.toString()}`; + link.textContent = item.title; + children.push(link); + } + }); + host.replaceChildren(...children); + }; + const parseJsonScript = (id) => { const node = document.getElementById(id); if (!node) return null; @@ -406,6 +475,33 @@ import { const ensureLocalFolderEventChannel = (...args) => documentSessions.ensureLocalFolderEventChannel(...args); const sessionMatchesDocumentWorkspace = (...args) => documentSessions.sessionMatchesDocumentWorkspace(...args); const getOrCreateDocumentSession = (...args) => documentSessions.getOrCreateDocumentSession(...args); + const setDocumentSessionUserReadOnlyMode = (...args) => documentSessions.setDocumentSessionUserReadOnlyMode(...args); + + const syncDocumentEditModeChrome = (session) => { + if (!session) return; + sessionViews(session).forEach((view) => { + const pane = view.runtimeDescriptor.root.closest('[data-document-pane="true"]'); + const shell = view.runtimeDescriptor.root.closest('.document-shell'); + if (shell instanceof HTMLElement) { + shell.setAttribute('data-document-user-readonly-mode', String(session.userReadOnlyMode !== false)); + shell.setAttribute('data-document-effective-readonly', String(session.readOnly === true)); + } + const titleInput = pane?.querySelector('[data-page-title-input="true"]'); + if (titleInput instanceof HTMLTextAreaElement) { + titleInput.readOnly = session.readOnly === true; + titleInput.setAttribute('data-document-user-readonly-mode', String(session.userReadOnlyMode !== false)); + } + const toggle = pane?.querySelector('[data-document-edit-mode-toggle]'); + if (toggle instanceof HTMLButtonElement) { + const editing = session.readOnly !== true; + toggle.textContent = editing ? '退出编辑' : '开始编辑'; + toggle.title = editing ? '切回只读浏览' : '进入编辑模式'; + toggle.setAttribute('aria-pressed', String(editing)); + toggle.setAttribute('data-document-editing', String(editing)); + toggle.disabled = session.permissionReadOnly === true && session.userReadOnlyMode === false; + } + }); + }; const updatePaneChrome = (runtimeDescriptor) => { const pane = runtimeDescriptor.root.closest('[data-document-pane="true"]'); @@ -467,8 +563,7 @@ import { document.body.dataset.mnoteShell = 'document'; delete document.body.dataset.mindmapId; document.title = title; - const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]'); - if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title; + refreshBreadcrumb(documentId, title, workspaceId); window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', { detail: { documentId, @@ -566,13 +661,20 @@ import { titleInput.setAttribute('data-title-endpoint', '/api/documents/title'); titleInput.rows = 1; titleHeading.appendChild(titleInput); + const modeToggle = document.createElement('button'); + modeToggle.type = 'button'; + modeToggle.className = 'document-edit-mode-toggle'; + modeToggle.setAttribute('data-document-edit-mode-toggle', 'true'); + modeToggle.setAttribute('aria-pressed', 'false'); + modeToggle.setAttribute('data-document-editing', 'false'); + modeToggle.textContent = '开始编辑'; const meta = document.createElement('div'); meta.className = 'document-shell-meta'; meta.setAttribute('aria-label', '页面元信息'); const currentTitle = document.createElement('span'); currentTitle.setAttribute('data-page-title-current', 'true'); meta.appendChild(currentTitle); - header.append(titleHeading, meta); + header.append(titleHeading, modeToggle, meta); const aggregateMarker = document.createElement('section'); aggregateMarker.setAttribute('data-page-aggregate-snapshot', 'mnote.page_aggregate.v1'); @@ -636,6 +738,7 @@ import { } const session = getOrCreateDocumentSession(runtimeDescriptor); const view = createEditorViewBinding(session, runtime, runtimeDescriptor); + syncDocumentEditModeChrome(session); const mountOptions = { documentId: session.documentId, workspaceId: session.workspaceId, @@ -657,6 +760,7 @@ import { setStatus(runtimeDescriptor, 'mounting-editor'); enhanceEditorAttachmentLinksSoon(); paneViewRegistry.set(paneRole, view); + syncDocumentEditModeChrome(session); return view; } catch (error) { unmountEditorViewBinding(view); @@ -739,6 +843,24 @@ import { anchor.removeAttribute('href'); }, true); document.addEventListener('click', (event) => { + const editToggle = event.target instanceof Element + ? event.target.closest('[data-document-edit-mode-toggle]') + : null; + if (editToggle instanceof HTMLButtonElement) { + event.preventDefault(); + const pane = editToggle.closest('[data-document-pane="true"]'); + const documentId = pane?.getAttribute('data-pane-document-id') || document.body?.dataset.documentId || ''; + const session = Array.from(documentSessionRegistry.values()) + .find((item) => item && item.documentId === documentId); + if (!session) return; + setDocumentSessionUserReadOnlyMode( + session, + !(session.userReadOnlyMode !== false), + 'mnote-web-document-edit-mode-toggle', + ); + syncDocumentEditModeChrome(session); + return; + } const target = event.target; const anchor = target instanceof Element ? target.closest('a.mnote-page-block-link') : null; if (!(anchor instanceof HTMLAnchorElement)) return; @@ -1022,6 +1144,7 @@ import { const runtime = await loadRuntime(); const session = getOrCreateDocumentSession(runtimeDescriptor); const view = createEditorViewBinding(session, runtime, runtimeDescriptor); + syncDocumentEditModeChrome(session); const mountOptions = { documentId: session.documentId, @@ -1045,6 +1168,7 @@ import { setStatus(runtimeDescriptor, 'mounting-editor'); enhanceEditorAttachmentLinksSoon(); paneViewRegistry.set(runtimeDescriptor.paneRole, view); + syncDocumentEditModeChrome(session); } catch (error) { unmountEditorViewBinding(view); throw error; diff --git a/rust/crates/mnote-web/browser/document-session-runtime.js b/rust/crates/mnote-web/browser/document-session-runtime.js index 9f9b90d2..8f88d468 100644 --- a/rust/crates/mnote-web/browser/document-session-runtime.js +++ b/rust/crates/mnote-web/browser/document-session-runtime.js @@ -28,6 +28,34 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const SESSION_RELEASE_DELAY_MS = 1200; const LOCAL_FOLDER_SELF_CHANGE_SUPPRESSIONS_KEY = 'mnote.localFolder.selfChangeSuppressions.v1'; + const effectiveSessionReadOnly = (session) => Boolean(session?.permissionReadOnly || session?.userReadOnlyMode); + + const applySessionPermissionReadOnly = (session, permissionReadOnly) => { + if (!session) return false; + session.permissionReadOnly = Boolean(permissionReadOnly); + session.readOnly = effectiveSessionReadOnly(session); + return session.readOnly; + }; + + const setDocumentSessionUserReadOnlyMode = (session, userReadOnlyMode, source) => { + if (!session) return false; + session.userReadOnlyMode = Boolean(userReadOnlyMode); + session.readOnly = effectiveSessionReadOnly(session); + sessionViews(session).forEach((view) => { + if (view.mountId == null) return; + dispatchRuntimeCommand(view, { + command: 'setEditable', + documentId: session.documentId, + workspaceId: session.workspaceId, + title: session.title, + readOnly: session.readOnly, + editable: !session.readOnly, + }, source || 'mnote-web-document-user-readonly-mode'); + }); + setSessionStatus(session, session.readOnly ? 'read-only' : 'editable'); + return session.readOnly; + }; + const ensureLocalFolderSelfChangeSuppressions = () => { const now = Date.now(); const map = window.__mnoteLocalFolderSelfChangeSuppressions instanceof Map @@ -660,7 +688,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { session.revision = nextRevision; session.conflictDetectionKey = nextConflictKey; session.lastExternalConflictDetectionKey = nextConflictKey || ''; - session.readOnly = Boolean(nextPermissions.readOnly); + applySessionPermissionReadOnly(session, Boolean(nextPermissions.readOnly)); session.dirty = false; session.hasExternalConflict = false; session.externalChangePending = false; @@ -1315,7 +1343,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { session.revision = nextRevision; session.conflictDetectionKey = nextConflictKey; session.lastExternalConflictDetectionKey = nextConflictKey || ''; - session.readOnly = Boolean(nextPermissions.readOnly); + applySessionPermissionReadOnly(session, Boolean(nextPermissions.readOnly)); session.dirty = false; session.hasExternalConflict = false; session.lastUserInputAt = 0; @@ -1831,7 +1859,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { lastExternalConflictDetectionKey: conflictDetectionKey || '', relativePath: localMarkdownRelativePathFromDocumentId(runtimeDescriptor.bootstrap.documentId), bufferDirtyState: 'Clean', - readOnly: Boolean(permissions.readOnly), + permissionReadOnly: Boolean(permissions.readOnly), + userReadOnlyMode: true, + readOnly: true, dirty: false, saving: false, hasExternalConflict: false, @@ -1849,6 +1879,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { localFolderChannel: null, }; ensureLocalFolderEventChannel(session); + session.readOnly = effectiveSessionReadOnly(session); return session; }; @@ -1885,6 +1916,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { sessionHasRecentLocalInput, sessionMatchesDocumentWorkspace, sessionViews, + setDocumentSessionUserReadOnlyMode, setSessionStatus, }; }; diff --git a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js index 86e352ab..1102308d 100644 --- a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js +++ b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js @@ -389,6 +389,21 @@ export const localMarkdownRelativePathFromDocumentId = (documentId) => { return decodeLocalIdSegment(segment).replace(/^\/+/, ''); }; +export const localMarkdownRelativePathFromDocumentsHref = (href) => { + const value = String(href || '').trim(); + if (!value) return ''; + let url = null; + try { + url = new URL(value, window.location?.origin || 'http://127.0.0.1:3000'); + } catch (_) { + return ''; + } + if (!url.pathname.startsWith('/documents/')) return ''; + const segment = decodeURIComponent(url.pathname.slice('/documents/'.length)); + if (!segment.startsWith('local-md:')) return ''; + return localMarkdownRelativePathFromDocumentId(segment); +}; + export const localMarkdownDocumentIdFromRelativePath = (relativePath) => { const normalized = String(relativePath || '').trim().replace(/^\/+/, ''); if (!normalized) return ''; @@ -440,7 +455,12 @@ export const normalizeLocalPageRelativePath = (value, context) => { if (!text || text.startsWith('#')) return ''; if (text.startsWith('/documents/')) return text; if (text.startsWith('/api/') || text.startsWith('http://') || text.startsWith('https://') || text.startsWith('mailto:')) return ''; - const baseDir = text.startsWith('/') ? '' : localMarkdownDirectoryFromDocumentId(context?.documentId); + const currentPath = localMarkdownRelativePathFromDocumentId(context?.documentId); + const textPath = text.replace(/^\/+/, ''); + const currentTop = currentPath.split('/').filter(Boolean)[0] || ''; + const textTop = textPath.split('/').filter(Boolean)[0] || ''; + const looksRootRelative = Boolean(currentTop && textTop && currentTop === textTop); + const baseDir = text.startsWith('/') || looksRootRelative ? '' : localMarkdownDirectoryFromDocumentId(context?.documentId); const parts = (baseDir ? `${baseDir}/${text}` : text) .replace(/\\/g, '/') .split('/'); @@ -752,6 +772,32 @@ export const tiptapNodeToEditorBlock = (node, index) => { childBlockIds: [], }; } + if (node?.type === 'paragraph') { + const children = Array.isArray(node?.content) ? node.content : []; + if (children.length === 1 && children[0]?.type === 'text') { + const textNode = children[0]; + const linkMark = Array.isArray(textNode.marks) + ? textNode.marks.find((mark) => { + const className = String(mark?.attrs?.class || ''); + return mark?.type === 'link' && className.split(/\s+/).includes('mnote-page-block-link'); + }) + : null; + if (linkMark?.attrs?.href) { + const href = String(linkMark.attrs.href || '').trim(); + const sourcePath = localMarkdownRelativePathFromDocumentsHref(href) || unwrapMarkdownLinkTarget(href); + return { + blockId, + blockType: 'page_reference', + props: { + title: String(textNode.text || '').trim() || '页面', + sourcePath, + }, + contentNodes: inlineTextNodes(node), + childBlockIds: [], + }; + } + } + } if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] }; if (node?.type === 'heading') { const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1)); @@ -795,6 +841,11 @@ export const legacyBlocksFromEditorDocument = (editorDocument) => ( ? { language: block.props?.language || null } : block.blockType === 'mindmap' ? mindmapPropsFromAttrs(block.props || {}, block.blockId) + : block.blockType === 'page_reference' + ? { + title: block.props?.title || flattenText(block.contentNodes || []) || '页面', + sourcePath: block.props?.sourcePath || block.props?.source_path || block.props?.href || '', + } : block.blockType === 'image' ? { ...(block.props || {}) } : block.blockType === 'toc' diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js index d6ad70dd..04f39ced 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js @@ -300,7 +300,14 @@ return vm; } if (event.type === 'assistant_abort') { + // S4: abort is idempotent — second apply (SSE after optimistic) must not mutate again. + if (vm.runtimeStatus === STATE_ABORTED && vm.abortResponse) { + return vm; + } var abortMsg = vm.currentAssistant || findLastAssistant(vm) || ensurePiRunAssistant(vm); + if (abortMsg.status === 'aborted' && !vm.currentAssistant) { + return vm; + } abortMsg.status = 'aborted'; abortMsg.abortReason = event.reason || payload.reason || payload.stopReason || payload.error || payload.message || '已中止'; abortMsg.abortResponse = payload || { reason: abortMsg.abortReason }; @@ -3572,15 +3579,31 @@ ]) .then(function (results) { var data = results[0] || {}; + var effectiveData = results[1] || null; piLabState.enabled = !!data.enabled; piLabState.sessionId = data.sessionId || piLabState.sessionId || null; piLabState.providerSessionId = data.providerSessionId || piLabState.providerSessionId || null; - // Consume backend status/default fields when present. - if (data.defaultModelProvider) piLabState.defaultModelProvider = data.defaultModelProvider; - if (data.defaultModelId) piLabState.defaultModelId = data.defaultModelId; - if (data.defaultThinkingLevel) piLabState.defaultThinkingLevel = normalizeThinkingLevel(data.defaultThinkingLevel); - piLabState.modelProvider = (data.session && data.session.modelProvider) || piLabState.modelProvider || null; - piLabState.modelId = (data.session && data.session.modelId) || piLabState.modelId || null; + // S1: prefer effective AI settings as management-surface truth; status is + // fallback only when effective load failed. Do not let env-stale status + // overwrite a successfully loaded effective defaultModel. + if (effectiveData) { + applyEffectiveConfig(effectiveData); + } else { + if (data.defaultModelProvider) piLabState.defaultModelProvider = data.defaultModelProvider; + if (data.defaultModelId) piLabState.defaultModelId = data.defaultModelId; + } + if (data.defaultThinkingLevel && !piLabState.defaultThinkingLevel) { + piLabState.defaultThinkingLevel = normalizeThinkingLevel(data.defaultThinkingLevel); + } + // Active session model wins; otherwise keep effective defaults already applied. + piLabState.modelProvider = (data.session && data.session.modelProvider) + || piLabState.modelProvider + || piLabState.defaultModelProvider + || null; + piLabState.modelId = (data.session && data.session.modelId) + || piLabState.modelId + || piLabState.defaultModelId + || null; var sessionThinkingLevel = data.session && data.session.thinkingLevel; if (sessionThinkingLevel) { piLabState.thinkingLevel = normalizeThinkingLevel(sessionThinkingLevel); @@ -3851,6 +3874,8 @@ es.close(); piLabEventSource = null; } + // S4: optimistic abort already applied UI; ignore duplicate SSE abort. + if (piLabState.status === STATE_ABORTED) return; applyPiRunEvent({ type: 'assistant_abort', payload: payload }); setState(STATE_ABORTED); updateMessages(); @@ -4056,9 +4081,12 @@ var deltaToolPayload = assistantToolCallPayload(msgData); if (deltaToolPayload) upsertToolCall(deltaToolPayload, 'running', { args: deltaToolPayload.args || {} }); } else if (eventType === 'response' && payload.command === 'abort') { - applyPiRunEvent({ type: 'assistant_abort', payload: payload }); - setState(STATE_ABORTED); - updateMessages(); + // S4: ignore duplicate abort after optimistic UI apply. + if (piLabState.status !== STATE_ABORTED) { + applyPiRunEvent({ type: 'assistant_abort', payload: payload }); + setState(STATE_ABORTED); + updateMessages(); + } } else if (eventType === 'citation' || msgDataType === 'citation') { applyPiRunEvent({ type: 'citation', payload: { source: payload.source || msgData.source || '', title: payload.title || msgData.title || '', url: payload.url || msgData.url || '#' } }); updateMessages(); @@ -4088,15 +4116,132 @@ } } + function pageAiBlockingDirtyStateLocal(dirtyState) { + var state = String(dirtyState || '').trim(); + var normalized = state.toLowerCase(); + if (normalized === 'dirty') return 'Dirty'; + if (normalized === 'stale') return 'Stale'; + if (normalized === 'deleted') return 'Deleted'; + if (normalized === 'externalmodified' || normalized === 'external-change-conflict' || normalized === 'hasexternalconflict') return 'ExternalModified'; + return ''; + } + + function buildPiLabAgentTargetPackage(context) { + var active = activeEditorSnapshot() || {}; + var activeWorkspacePath = active.workspacePath && typeof active.workspacePath === 'object' ? active.workspacePath : {}; + var relativePath = normalizeSlashes(context && context.pagePath || activeWorkspacePath.relativePath || active.path || ''); + var rootUri = String(context && context.rootUri || activeWorkspacePath.rootUri || active.rootUri || '').trim(); + var workspaceId = String(context && context.workspaceId || activeWorkspacePath.workspaceId || active.workspaceId || '').trim(); + var documentId = String(context && context.pageId || active.documentId || activeWorkspacePath.documentId || '').trim(); + var resourceKind = String(activeWorkspacePath.resourceKind || active.resourceKind || active.kind || 'markdown_page').trim() || 'markdown_page'; + var objectIdentity = String(active.objectIdentity || activeWorkspacePath.objectIdentity || documentId || relativePath || '').trim(); + var onlyofficeSessionId = String(active.onlyofficeSessionId || active.bridgeSessionId || '').trim(); + var dirtyState = String(active.dirtyState || active.dirtyGuard || '').trim(); + var primaryTargetId = objectIdentity || documentId || relativePath; + var workspacePath = { + schema: 'mnote.workspace_path.v1', + workspaceId: workspaceId, + sourceKind: 'local_folder', + rootUri: rootUri, + relativePath: relativePath, + documentId: documentId, + objectIdentity: objectIdentity, + resourceKind: resourceKind, + }; + var targetEntry = { + targetId: primaryTargetId, + objectIdentity: objectIdentity, + documentId: documentId, + workspaceId: workspaceId, + sourceKind: 'local_folder', + rootUri: rootUri, + relativePath: relativePath, + resourceKind: resourceKind, + dirtyState: dirtyState, + onlyofficeSessionId: onlyofficeSessionId, + bridgeSessionId: onlyofficeSessionId, + paneRole: active.paneRole || 'primary', + title: context && context.pageTitle || active.title || '', + policy: { + permission: relativePath ? 'read_write' : 'read', + writeRequiresCleanBuffer: true, + conflictPolicy: 'fail_on_dirty_or_stale' + } + }; + return { + schema: 'mnote.agent_target_package.v1', + source: 'pi_lab_send_target_snapshot', + frozenAt: Date.now(), + primaryTargetId: primaryTargetId, + onlyofficeSessionId: onlyofficeSessionId, + bridgeSessionId: onlyofficeSessionId, + workspaceId: workspaceId, + sourceKind: 'local_folder', + rootUri: rootUri, + documentId: documentId, + objectIdentity: objectIdentity, + resourceKind: resourceKind, + workspacePath: workspacePath, + currentFile: relativePath ? { + rootUri: rootUri, + relativePath: relativePath, + documentId: documentId, + objectIdentity: objectIdentity, + resourceKind: resourceKind + } : null, + allowedFiles: relativePath ? [relativePath] : [], + targets: [targetEntry], + policy: { + writeRequiresExplicitTarget: true, + allowedFilesSource: 'selected_page_ai_target', + conflictPolicy: 'fail_on_dirty_or_stale' + } + }; + } + + function fetchPiLabTargetBufferState(context, targetPackage) { + var workspacePath = targetPackage && targetPackage.workspacePath || {}; + var documentId = String(context && context.pageId || workspacePath.documentId || '').trim(); + var rootUri = String(context && context.rootUri || workspacePath.rootUri || '').trim(); + if (!documentId || !rootUri) return Promise.resolve(null); + var relativePath = String(workspacePath.relativePath || context && context.pagePath || '').trim(); + var url = new URL('/api/documents/buffer-state', window.location.origin); + url.searchParams.set('documentId', documentId); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + var workspaceId = String(context && context.workspaceId || workspacePath.workspaceId || '').trim(); + if (workspaceId) url.searchParams.set('workspaceId', workspaceId); + if (relativePath) url.searchParams.set('relativePath', relativePath); + return fetch(url.toString(), { cache: 'no-store', credentials: 'same-origin', headers: { accept: 'application/json' } }) + .then(function (response) { + return response.json().catch(function () { return null; }).then(function (payload) { + if (!response.ok || !payload || payload.ok !== true) return null; + return payload.result || null; + }); + }) + .catch(function () { return null; }); + } + + function assertPiLabTargetWritable(context, targetPackage) { + var active = activeEditorSnapshot() || {}; + var snapshotDirty = pageAiBlockingDirtyStateLocal(active.dirtyState || active.dirtyGuard); + return fetchPiLabTargetBufferState(context, targetPackage).then(function (bufferState) { + var bufferDirty = pageAiBlockingDirtyStateLocal(bufferState && bufferState.dirtyState); + var blockedState = bufferDirty || snapshotDirty; + if (!blockedState) return bufferState; + var error = new Error('目标文档存在未保存或外部变更状态(' + blockedState + '),请先保存、解决冲突或刷新后再让 AI 写入。'); + error.code = 'page_ai_target_buffer_not_writable'; + error.documentId = String(context && context.pageId || '').trim(); + error.dirtyState = blockedState; + throw error; + }); + } + function sendPrompt(text, options) { var prompt = String(text || '').trim(); - if (!prompt) return; + if (!prompt) return Promise.resolve(); var streamingBehavior = options && options.streamingBehavior; var midStream = piLabState.status === STATE_STREAMING && streamingAssistantMsg; - applyPiRunEvent({ type: 'user_prompt', text: prompt, meta: midStream ? (streamingBehavior === 'followUp' ? 'follow-up queued' : 'steer') : '' }); - if (!midStream) applyPiRunEvent({ type: 'assistant_begin' }); - setState(STATE_STREAMING); - updateMessages(); var context = applyCurrentContextToState(); var contextSelection = selectedContextPayload(context); var refs = contextSelection.refs || []; @@ -4104,27 +4249,39 @@ var includeAnyContext = refs.length > 0; var includePageContext = refs.indexOf('current_page') >= 0 || refs.indexOf('selection') >= 0 || refs.indexOf('lightrag') >= 0; var includeFolderContext = refs.indexOf('folder') >= 0; - var body = { - sessionId: piLabState.sessionId, - message: prompt, - rootUri: includeAnyContext ? (context.rootUri || undefined) : undefined, - workspaceId: includeAnyContext ? (context.workspaceId || undefined) : undefined, - pagePath: includePageContext ? (context.pagePath || undefined) : undefined, - pageTitle: includePageContext ? (context.pageTitle || undefined) : undefined, - folderPath: includeFolderContext && selectedFolderPath !== null ? selectedFolderPath : undefined, - contextRefs: contextSelection.refs, - selectedContext: contextSelection, - }; - if (streamingBehavior) body.streamingBehavior = streamingBehavior; - if (piLabState.pendingImages && piLabState.pendingImages.length) { - body.images = piLabState.pendingImages; - piLabState.pendingImages = []; - } - return fetch(API.SEND, { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + var targetPackage = buildPiLabAgentTargetPackage(context); + // S7: block send when open target buffer is dirty/stale (fail_on_dirty_or_stale). + var writableGate = (context && context.pagePath) + ? assertPiLabTargetWritable(context, targetPackage) + : Promise.resolve(null); + return writableGate.then(function () { + applyPiRunEvent({ type: 'user_prompt', text: prompt, meta: midStream ? (streamingBehavior === 'followUp' ? 'follow-up queued' : 'steer') : '' }); + if (!midStream) applyPiRunEvent({ type: 'assistant_begin' }); + setState(STATE_STREAMING); + updateMessages(); + var body = { + sessionId: piLabState.sessionId, + message: prompt, + rootUri: includeAnyContext ? (context.rootUri || undefined) : undefined, + workspaceId: includeAnyContext ? (context.workspaceId || undefined) : undefined, + pagePath: includePageContext ? (context.pagePath || undefined) : undefined, + pageTitle: includePageContext ? (context.pageTitle || undefined) : undefined, + folderPath: includeFolderContext && selectedFolderPath !== null ? selectedFolderPath : undefined, + contextRefs: contextSelection.refs, + selectedContext: contextSelection, + targetPackage: targetPackage, + }; + if (streamingBehavior) body.streamingBehavior = streamingBehavior; + if (piLabState.pendingImages && piLabState.pendingImages.length) { + body.images = piLabState.pendingImages; + piLabState.pendingImages = []; + } + return fetch(API.SEND, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); }) .then(function (r) { return responseJsonOrError(r, 'Send failed'); @@ -4134,7 +4291,15 @@ if (!piLabEventSource || piLabEventSource.readyState === EventSource.CLOSED) connectEventSource(data.sessionId || piLabState.sessionId); }) .catch(function (err) { - if (streamingAssistantMsg) applyPiRunEvent({ type: 'assistant_error', message: err.message }); + var message = err && err.message ? err.message : 'Send failed'; + if (err && err.code === 'page_ai_target_buffer_not_writable') { + showPiToast(message, 'warning'); + updateDiagnostics(message); + setState(piLabState.sessionId ? STATE_STARTED : STATE_IDLE); + updateMessages(); + return; + } + if (streamingAssistantMsg) applyPiRunEvent({ type: 'assistant_error', message: message }); setState(STATE_STARTED); updateMessages(); }); @@ -4330,23 +4495,19 @@ piLabEventSource.close(); piLabEventSource = null; } - if (piLabState.sessionId) { - fetch(API.ABORT, { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: piLabState.sessionId }), - }).then(function (response) { - return response.json().catch(function () { return {}; }); - }).then(function (payload) { - applyPiRunEvent({ type: 'assistant_abort', payload: Object.assign({ stopReason: 'aborted' }, payload || {}) }); - updateMessages(); - }).catch(function () {}); - } - applyPiRunEvent({ type: 'assistant_abort', payload: { reason: '已中止', stopReason: 'aborted' } }); - setState(STATE_ABORTED); - updateMessages(); - } + // S4: apply abort UI state exactly once (optimistic). Do not re-apply from ABORT response. + applyPiRunEvent({ type: 'assistant_abort', payload: { reason: '已中止', stopReason: 'aborted' } }); + setState(STATE_ABORTED); + updateMessages(); + if (piLabState.sessionId) { + fetch(API.ABORT, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: piLabState.sessionId }), + }).catch(function () {}); + } + } function startNewConversation() { var previousSessionId = piLabState.sessionId; diff --git a/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js index cb8f29ba..d37cd85d 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js @@ -441,11 +441,6 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { function applyLocalFolderWatchBatch(payload) { var batch = payload && (payload.payload || payload); - if (batch && (batch.fallbackResync === true || batch.requiresResync === true)) { - document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback', String(batch.revision || 'resync')); - void refreshLocalFolderSidebarSnapshot(); - return true; - } var changedPaths = Array.isArray(batch && batch.changedPaths) ? batch.changedPaths : Array.isArray(batch && batch.changed_paths) @@ -456,6 +451,31 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { : Array.isArray(batch && batch.affected_parents) ? batch.affected_parents : []; + if (batch && (batch.fallbackResync === true || batch.requiresResync === true)) { + document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback', String(batch.revision || 'resync')); + var fallbackParents = new Set(); + affectedParents.forEach(function(parent) { + addCommandRefreshParent(fallbackParents, parent && (parent.relativePath || parent.relative_path)); + }); + changedPaths.forEach(function(item) { + var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim(); + addCommandRefreshParent(fallbackParents, parentRelativePathForPath(relativePath)); + }); + if (fallbackParents.size) { + document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback-scoped', String(fallbackParents.size)); + void Promise.all(Array.from(fallbackParents).map(function(parentRelativePath) { + return refreshFileTreeParent(parentRelativePath).catch(function(error) { + setTreeLiveApplyError(error && error.message ? error.message : '文件树 fallback scoped 刷新失败'); + return false; + }); + })).then(function() { + markLocalFolderWatchApplied('watch_batch_fallback_scoped'); + }); + } else { + void refreshLocalFolderSidebarSnapshot(); + } + return true; + } if (!affectedParents.length) { affectedParents = changedPaths.map(function(item) { var relativePath = String(item && (item.relativePath || item.relative_path) || '').trim(); @@ -714,7 +734,13 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { function sidebarTreeViewScope(treeKind) { var normalizedTreeKind = String(treeKind || '').trim() === 'pagetree' ? 'pagetree' : 'filetree'; var rootUri = String(currentRootUri() || '').trim(); - var scope = currentFileTreeScope() || 'root'; + // Sidex stores explorer view-state per workspace, not per current folder + // reveal path. PageTree must stay on a stable "root" scope so expand state + // survives refresh / navigation even when fileTreeScope changes. + // FileTree keeps scope = current fileTreeScope (folder-scoped projection). + var scope = normalizedTreeKind === 'pagetree' + ? 'root' + : (currentFileTreeScope() || 'root'); return { userId: currentActorStorageId(), workspaceId: String(currentWorkspaceId() || '').trim() || (rootUri ? 'local:' + rootUri.replace(/^file:\/\//, '').replace(/[^A-Za-z0-9]+/g, '_') : 'default'), @@ -797,6 +823,11 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { } state = normalizeSidebarTreeViewState(scope.treeKind, localValue, localValue ? 'local' : 'default'); sidebarTreeViewStates.set(key, state); + // Sidex applies stored view-state on setInput; apply local snapshot immediately + // so first paint / refresh does not wait for the control-plane GET. + if (state.hasUserState) { + applySidebarTreeViewState(scope.treeKind, state); + } void loadSidebarTreeViewState(scope.treeKind); } if (scope.treeKind === 'filetree') { @@ -827,9 +858,36 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { if (!result || !result.state) return null; var latestScope = sidebarTreeViewScope(scope.treeKind); if (sidebarTreeViewStateMapKey(latestScope) !== key) return null; - var loaded = normalizeSidebarTreeViewState(scope.treeKind, result.state, result.source === 'sqlite' ? 'sqlite' : 'default'); + var sourceTag = result.source === 'sqlite' || result.source === 'control-plane' ? 'sqlite' : 'default'; + var loaded = normalizeSidebarTreeViewState(scope.treeKind, result.state, sourceTag); + // Privacy / cold boot: async view-state must not clobber expand that the + // user already performed while this request was in flight (first-click + // expand would snap shut when default/empty state lands). + var current = sidebarTreeViewStates.get(key); + if (current && current.hasUserState) { + var currentUpdated = Number(current.updatedAtMs || 0) || 0; + var loadedUpdated = Number(loaded.updatedAtMs || 0) || 0; + if (currentUpdated > loadedUpdated) { + current.expandedIds.forEach(function(id) { loaded.expandedIds.add(id); }); + current.expandedRelativePaths.forEach(function(path) { loaded.expandedRelativePaths.add(path); }); + loaded.hasUserState = true; + loaded.updatedAtMs = Math.max(currentUpdated, loadedUpdated); + if (current.selectedId && !loaded.selectedId) loaded.selectedId = current.selectedId; + if (current.focusedId && !loaded.focusedId) loaded.focusedId = current.focusedId; + if (current.activeId && !loaded.activeId) loaded.activeId = current.activeId; + } else { + // Server is newer: still union in-session expands so a late GET never + // collapses a node the user just opened. + current.expandedIds.forEach(function(id) { loaded.expandedIds.add(id); }); + current.expandedRelativePaths.forEach(function(path) { loaded.expandedRelativePaths.add(path); }); + loaded.hasUserState = true; + } + } sidebarTreeViewStates.set(key, loaded); applySidebarTreeViewState(scope.treeKind, loaded); + if (scope.treeKind === 'pagetree') { + scheduleRestorePersistedPageTreeExpansionState(); + } return loaded; }); }).catch(function(error) { @@ -919,16 +977,30 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { if (!(row instanceof HTMLElement)) return; var nodeId = String(row.getAttribute('data-node-id') || '').trim(); var button = row.querySelector('[data-rust-action="toggle"]'); - var node = row.closest('.tree-node'); - var children = node ? node.querySelector(':scope > .tree-children') : null; + var children = pageTreeChildrenContainer(row); if (!nodeId || !button || !(children instanceof HTMLElement)) return; var expanded = state.expandedIds.has(nodeId); - row.setAttribute('aria-expanded', String(expanded)); - button.setAttribute('aria-expanded', String(expanded)); - children.classList.toggle('tree-children--collapsed', !expanded); + // Keep rows the user already opened this session (and that are actually + // showing children) even if a stale view-state snapshot omitted the id. + if (!expanded && pageTreeRowIsOpen(row)) { + state.expandedIds.add(nodeId); + expanded = true; + } + var hasChildRows = children.children.length > 0; + // Only open presentation when children exist in DOM. Empty bodies stay + // collapsed (icon closed); restorePersistedPageTreeExpansionState hydrates. + var openNow = expanded && hasChildRows; + setPageTreeExpandPresentation(row, button, openNow, { + children: children, + forceCollapsed: !openNow + }); + if (openNow) { + row.setAttribute('data-page-tree-children-loaded', 'true'); + } changed = true; }); if (changed) document.documentElement.setAttribute('data-mnote-page-tree-view-state-applied', state.scope || 'root'); + syncPageTreeExpandVisualState(); return changed; } @@ -1016,6 +1088,52 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return ''; } + function pageTreeRelativePathFromItem(item) { + if (!item || typeof item !== 'object') return ''; + var direct = String(item.expandRelativePath || item.relativePath || '').trim(); + if (direct) return direct.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + var meta = item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {}; + var workspacePath = meta.workspacePath && typeof meta.workspacePath === 'object' ? meta.workspacePath : {}; + var fromWorkspace = String(workspacePath.relativePath || '').trim(); + if (fromWorkspace) return fromWorkspace.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + var extra = meta.extra && typeof meta.extra === 'object' ? meta.extra : {}; + var source = extra.source && typeof extra.source === 'object' ? extra.source : {}; + return String(source.relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + } + + function pageTreeExpandRelativePath(relativePath) { + var rp = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + if (!rp) return ''; + if (!/\.md$/i.test(rp)) return rp; + var stem = rp.slice(0, -3); + var slash = rp.lastIndexOf('/'); + if (slash >= 0) { + var parent = rp.slice(0, slash); + var file = rp.slice(slash + 1); + var fileStem = file.replace(/\.md$/i, ''); + var parentName = parent.indexOf('/') >= 0 ? parent.slice(parent.lastIndexOf('/') + 1) : parent; + if (parentName === fileStem) return parent; + return stem; + } + return stem; + } + + function pageTreeExpandPathFromRow(row) { + if (!(row instanceof HTMLElement)) return ''; + var direct = String(row.getAttribute('data-local-relative-path') || '').trim(); + if (direct) return direct.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + var nodeId = String(row.getAttribute('data-node-id') || '').trim(); + // local-md: is not human path; fall back empty and let API skip. + if (nodeId.indexOf('local-dir:') === 0) { + try { + return decodeURIComponent(nodeId.slice('local-dir:'.length).replace(/~/g, '%')); + } catch (_) { + return nodeId.slice('local-dir:'.length); + } + } + return ''; + } + function renderPageRows(parentId, grouped, activeId, inheritedDepth) { var computedDepth = Number(inheritedDepth || 0); var pageTreeStateView = sidebarTreeViewStateFor('pagetree'); @@ -1028,20 +1146,37 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var parent = parentIdOf(item); var children = grouped.get(nodeId) || []; var expandable = Boolean(item.expandable || item.childCount > 0 || children.length); - var expanded = expandable && ( + // Align with SSR (expandedByDefault defaults false) and Sidex view-state: + // only expand when user state lists the id, or (no user state yet) the + // active ancestor / explicit expandedByDefault === true. + // IMPORTANT: never paint aria/data-expanded=true for an empty children UL. + // Lazy scope hydrate only has one level of rows; nested expandedIds are + // restored by restorePersistedPageTreeExpansionState cascade, not by + // faking an open empty container (that caused ▼ with no children after refresh). + var wantExpanded = expandable && ( pageTreeExpandedIds.has(nodeId) || - (!pageTreeStateView.hasUserState && (activeParentIds.has(nodeId) || item.expandedByDefault !== false)) + (!pageTreeStateView.hasUserState && ( + activeParentIds.has(nodeId) || + item.expandedByDefault === true + )) ); + var expanded = wantExpanded && children.length > 0; var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {}; var openable = Boolean(meta.documentId || item.documentId || !String(nodeId).startsWith('local-dir:')); + var expandPath = pageTreeExpandRelativePath(pageTreeRelativePathFromItem(item)); + if (!expandPath && expandable) { + expandPath = pageTreeExpandRelativePath(String(item.expandRelativePath || item.relativePath || '')); + } + var pathAttr = expandPath ? ' data-local-relative-path="' + escapeHtml(expandPath) + '"' : ''; + var expandedAttr = String(Boolean(expandable && expanded)); var toggle = expandable - ? '' + ? '' : ''; var childHtml = expandable ? '
    ' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '
' : ''; var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"'; - return '
  • ' + toggle + '
    ' + childHtml + '
  • '; + return '
  • ' + toggle + '
    ' + childHtml + '
  • '; }).join(''); } @@ -1065,6 +1200,14 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return false; } tree.replaceChildren(template.content.cloneNode(true)); + // Re-apply persisted expand after full projection replace (Sidex setInput + viewState). + var pageState = sidebarTreeViewStateFor('pagetree'); + if (pageState.hasUserState) { + applyPageTreeExpansionState(pageState); + scheduleRestorePersistedPageTreeExpansionState(); + } else { + syncPageTreeExpandVisualState(); + } return true; } @@ -1165,10 +1308,19 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var iconKind = iconKindOf(item); var indexStatus = String((item && item.indexStatus) || (item && item.resourceMeta && item.resourceMeta.indexStatus) || '').trim(); if (!['indexed', 'indexing', 'failed'].includes(indexStatus)) indexStatus = ''; + // S5: FileTree tooltip shows RAG index status when known. + var indexStatusLabel = indexStatus === 'indexed' + ? '已索引' + : indexStatus === 'indexing' + ? '索引中' + : indexStatus === 'failed' + ? '索引失败' + : ''; var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : []; var title = isFileTreeProjectionPageRow(rowKind, assetId) ? fileTreePageTitle(rawTitle) : normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity); + var linkTitle = indexStatusLabel ? (title + ' · ' + indexStatusLabel) : title; var children = grouped.get(nodeId) || []; var expandable = Boolean(item.expandable || item.childCount > 0 || children.length || cachedChildren.length); var expanded = expandable && (expandedRelativePaths.has(relativePath) || (!fileTreeStateView.hasUserState && item.expandedByDefault !== false)); @@ -1188,7 +1340,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var childHtml = expandable && expanded && childRowsHtml ? '
      ' + childRowsHtml + '
    ' : ''; - return '
  • ' + toggle + '
    ' + createAction + '
    ' + childHtml + '
  • '; + return '
  • ' + toggle + '
    ' + createAction + '
    ' + childHtml + '
  • '; }).join(''); } @@ -1402,6 +1554,9 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { var template = document.createElement('template'); template.innerHTML = '
      ' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '') + '
    '; tree.replaceChildren(template.content.cloneNode(true)); + // Root hydration replaces pending shell; mark FileTree ready for shell-first home. + tree.setAttribute('data-filetree-ssr', 'ready'); + tree.removeAttribute('aria-busy'); reprojectFileTreeSelectionState(); scheduleRestorePersistedFileTreeExpansionState(); scheduleHydrateVisibleExpandedFileTreeRows('render'); @@ -1534,6 +1689,42 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return readProjection(payload && (payload.result || payload)); } + function isPendingFileTreeShell() { + var root = document.getElementById('sidebar-file-tree-root'); + if (!(root instanceof HTMLElement)) return false; + if (root.getAttribute('data-filetree-ssr') === 'pending') return true; + return !!root.querySelector('[data-filetree-ssr="pending"], [data-rust-filetree-renderer="pending_shell_v1"]'); + } + + async function hydratePendingFileTreeShell(reason) { + if (!isPendingFileTreeShell()) return false; + var root = document.getElementById('sidebar-file-tree-root'); + if (root instanceof HTMLElement) { + root.setAttribute('data-filetree-ssr', 'hydrating'); + root.setAttribute('aria-busy', 'true'); + } + var fileTreeScope = currentFileTreeScope(); + var rendered = false; + try { + rendered = await refreshFileTreeParent(fileTreeScope); + } catch (error) { + rendered = false; + if (typeof console !== 'undefined' && typeof console.warn === 'function') { + console.warn('[mnote] hydratePendingFileTreeShell failed', reason || 'boot', error); + } + } + if (root instanceof HTMLElement) { + if (rendered) { + root.setAttribute('data-filetree-ssr', 'ready'); + root.removeAttribute('aria-busy'); + } else { + root.setAttribute('data-filetree-ssr', 'pending'); + root.setAttribute('aria-busy', 'true'); + } + } + return rendered; + } + async function refreshFileTreeParent(parentRelativePath) { ensureFileTreeLazyCacheScope(); var key = currentFileTreeParentKey(parentRelativePath); @@ -1658,24 +1849,137 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return ''; } - function setTreeRowExpanded(row, button, expanded, options) { - row.setAttribute('aria-expanded', expanded ? 'true' : 'false'); - var shellMode = row.getAttribute('data-shell-mode') || ''; - if (button) { - button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); - if (shellMode === 'filetree') button.textContent = expanded ? '▾' : '▸'; + function pageTreeChildrenContainer(row) { + if (!(row instanceof HTMLElement)) return null; + var node = row.closest('.tree-node'); + return node ? node.querySelector(':scope > .tree-children') : null; + } + + // Single write path for page-tree expand presentation (Sidex twistie model): + // aria-expanded + data-expanded + children class stay one unit so chevron CSS + // and click logic never disagree after SSR / refresh / restore. + function setPageTreeExpandPresentation(row, button, expanded, options) { + options = options || {}; + var children = options.children instanceof HTMLElement + ? options.children + : pageTreeChildrenContainer(row); + var open = Boolean(expanded); + // Never paint open/▼ for an empty children container unless explicitly allowed + // (in-flight optimistic expand before fetch returns). + if ( + open && + children instanceof HTMLElement && + children.children.length === 0 && + options.allowEmptyOpen !== true + ) { + open = false; + if (options.forceCollapsed !== false) options.forceCollapsed = true; } - var shouldPersist = !(options && options.persist === false); + var attr = open ? 'true' : 'false'; + if (row instanceof HTMLElement) { + row.setAttribute('aria-expanded', attr); + row.setAttribute('data-expanded', attr); + } + if (button instanceof HTMLElement) { + button.setAttribute('aria-expanded', attr); + button.setAttribute('data-expanded', attr); + var title = ''; + try { + title = String(rowTitle(row) || '').trim(); + } catch (_) { + title = String( + (row && row.querySelector('.tree-link-title') && row.querySelector('.tree-link-title').textContent) || '' + ).trim(); + } + button.setAttribute('aria-label', (open ? '折叠 ' : '展开 ') + title); + } + if (children instanceof HTMLElement) { + var forceCollapsed = options.forceCollapsed; + if (forceCollapsed === true) { + children.classList.add('tree-children--collapsed'); + } else if (forceCollapsed === false) { + children.classList.remove('tree-children--collapsed'); + } else { + children.classList.toggle('tree-children--collapsed', !open); + } + if (open && children.children.length > 0 && forceCollapsed !== true) { + if (row instanceof HTMLElement) { + row.setAttribute('data-page-tree-children-loaded', 'true'); + } + } + } + } + + // Visual open only: children visible with rows. Never trust aria alone. + function pageTreeRowIsOpen(row) { + if (!(row instanceof HTMLElement)) return false; + var children = pageTreeChildrenContainer(row); + if (!(children instanceof HTMLElement)) return false; + if (children.classList.contains('tree-children--collapsed')) return false; + return children.children.length > 0; + } + + // Icon and aria always follow visible children (Sidex twistie = tree open state). + // Never leave chevron open while .tree-children--collapsed / empty — that was the + // refresh desync. Restoring expandedIds re-opens children; icon follows after. + function syncPageTreeExpandVisualState() { + var changed = false; + document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]').forEach(function(row) { + if (!(row instanceof HTMLElement)) return; + var button = row.querySelector('[data-rust-action="toggle"]'); + if (!button) return; + var children = pageTreeChildrenContainer(row); + if (!(children instanceof HTMLElement)) { + if (row.getAttribute('aria-expanded') === 'true' || row.getAttribute('data-expanded') === 'true') { + setPageTreeExpandPresentation(row, button, false, { children: null }); + changed = true; + } + return; + } + var hasChildRows = children.children.length > 0; + var visuallyOpen = !children.classList.contains('tree-children--collapsed') && hasChildRows; + var ariaOpen = row.getAttribute('aria-expanded') === 'true'; + var dataOpen = row.getAttribute('data-expanded') === 'true'; + var buttonOpen = button.getAttribute('aria-expanded') === 'true' + || button.getAttribute('data-expanded') === 'true'; + if (ariaOpen !== visuallyOpen || dataOpen !== visuallyOpen || buttonOpen !== visuallyOpen) { + setPageTreeExpandPresentation(row, button, visuallyOpen, { + children: children, + forceCollapsed: !visuallyOpen + }); + changed = true; + } + if (hasChildRows) { + row.setAttribute('data-page-tree-children-loaded', 'true'); + } + }); + if (changed) { + document.documentElement.setAttribute('data-mnote-page-tree-expand-synced', String(Date.now())); + } + return changed; + } + + function setTreeRowExpanded(row, button, expanded, options) { + var shellMode = row.getAttribute('data-shell-mode') || ''; if (shellMode === 'page') { + setPageTreeExpandPresentation(row, button, expanded); + var shouldPersistPage = !(options && options.persist === false); var nodeId = String(row.getAttribute('data-node-id') || '').trim(); if (!nodeId) return; var pageState = sidebarTreeViewStateFor('pagetree'); if (expanded) pageState.expandedIds.add(nodeId); else pageState.expandedIds.delete(nodeId); pageState.hasUserState = true; - if (shouldPersist) persistSidebarTreeViewState('pagetree'); + if (shouldPersistPage) persistSidebarTreeViewState('pagetree'); return; } + row.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + row.setAttribute('data-expanded', expanded ? 'true' : 'false'); + if (button) { + button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + if (shellMode === 'filetree') button.textContent = expanded ? '▾' : '▸'; + } + var shouldPersist = !(options && options.persist === false); var relativePath = localFileTreeRelativePathFromRow(row); if (!relativePath) return; if (expanded) fileTreeExpandedRelativePaths.add(relativePath); @@ -1838,9 +2142,199 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return request; } + async function fetchPageTreeProjection(parentRelativePath) { + var rootUri = currentRootUri(); + if (!rootUri) return null; + var url = new URL('/api/tree/projections/page', window.location.origin); + var workspaceId = currentWorkspaceId(); + if (workspaceId) url.searchParams.set('workspaceId', workspaceId); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + if (parentRelativePath) url.searchParams.set('parentRelativePath', parentRelativePath); + var response = await fetch(url.toString(), { + credentials: 'include', + headers: { accept: 'application/json' }, + cache: 'no-store' + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) { + throw new Error(payload && (payload.error || payload.message) || 'page_tree_projection_failed_' + response.status); + } + return readProjection(payload && (payload.result || payload)); + } + + function patchPageTreeParentChildren(row, button, childItems, options) { + if (!(row instanceof HTMLElement)) return false; + var node = row.closest('.tree-node'); + if (!node) return false; + var children = node.querySelector(':scope > .tree-children'); + if (!children) { + children = document.createElement('ul'); + children.className = 'tree-children'; + node.appendChild(children); + } + var parentNodeId = String(row.getAttribute('data-node-id') || '').trim(); + var parentDepth = Number(row.getAttribute('data-depth') || 0); + // Scope projection returns one shallow level under parentRelativePath. Server parentNodeId + // often points at a directory/group id that is not present in this response. Do NOT run + // groupRowsByParent here: it rewrites unknown parents to "" and renderPageRows(parentId) + // then paints zero children (page-tree expand appears broken). + var scoped = (Array.isArray(childItems) ? childItems : []).map(function(item) { + var next = Object.assign({}, item); + next.parentNodeId = parentNodeId; + next.parentId = parentNodeId; + // Preserve expandable for nested lazy expand even when childCount is still 0. + if (next.expandable == null && Number(next.childCount || 0) > 0) { + next.expandable = true; + } + return next; + }); + var activeId = currentDocumentId(); + var grouped = new Map(); + grouped.set(parentNodeId, scoped); + var html = renderPageRows(parentNodeId, grouped, activeId, parentDepth + 1); + var template = document.createElement('template'); + template.innerHTML = html; + children.replaceChildren(template.content.cloneNode(true)); + row.setAttribute('data-page-tree-children-loaded', 'true'); + if (scoped.length === 0) { + // Scope returned no children: keep closed (▶) and stop restore retries. + children.classList.add('tree-children--collapsed'); + setTreeRowExpanded(row, button, false, options); + row.setAttribute('data-page-tree-restore-empty', 'true'); + return false; + } + children.classList.remove('tree-children--collapsed'); + row.removeAttribute('data-page-tree-restore-empty'); + setTreeRowExpanded(row, button, true, options); + return true; + } + + async function loadPageTreeChildren(row, button, options) { + if (!(row instanceof HTMLElement)) return false; + if (row.getAttribute('data-shell-mode') !== 'page') return false; + if (currentSourceKind() !== 'local_folder') return false; + var node = row.closest('.tree-node'); + var children = node ? node.querySelector(':scope > .tree-children') : null; + // SSR often already painted nested rows under a collapsed container without + // data-page-tree-children-loaded. Treat non-empty children as loaded so the + // first click expands immediately (no "dead" click waiting on fetch). + if (children && children.children.length > 0) { + row.setAttribute('data-page-tree-children-loaded', 'true'); + children.classList.remove('tree-children--collapsed'); + setTreeRowExpanded(row, button, true, options); + // Nested expandedIds may still need cascade hydrate after SSR paint. + if (!(options && options.skipCascadeRestore)) { + scheduleRestorePersistedPageTreeExpansionState(); + } + return true; + } + var alreadyLoaded = row.getAttribute('data-page-tree-children-loaded') === 'true'; + if (alreadyLoaded && children) { + children.classList.remove('tree-children--collapsed'); + setTreeRowExpanded(row, button, true, options); + if (!(options && options.skipCascadeRestore)) { + scheduleRestorePersistedPageTreeExpansionState(); + } + return true; + } + if (row.getAttribute('data-page-tree-children-loading') === 'true') { + // In-flight lazy load: optimistic empty-open so first click responds; + // hydrate completion rewrites children and re-syncs icons. + if (children) children.classList.remove('tree-children--collapsed'); + setPageTreeExpandPresentation(row, button, true, { + children: children, + forceCollapsed: false, + allowEmptyOpen: true + }); + return false; + } + var relativePath = pageTreeExpandPathFromRow(row); + if (!relativePath) { + // Expandable without path: toggle empty container only. + if (children) { + children.classList.remove('tree-children--collapsed'); + setTreeRowExpanded(row, button, true, options); + } + return false; + } + var rootUri = currentRootUri(); + if (!rootUri) return false; + // Optimistic UI: allow empty-open chevron until fetch fills children. + if (children) children.classList.remove('tree-children--collapsed'); + setPageTreeExpandPresentation(row, button, true, { + children: children, + forceCollapsed: false, + allowEmptyOpen: true + }); + // Still record expanded id for persistence while hydrate runs. + var nodeId = String(row.getAttribute('data-node-id') || '').trim(); + if (nodeId && !(options && options.persist === false)) { + var pageState = sidebarTreeViewStateFor('pagetree'); + pageState.expandedIds.add(nodeId); + pageState.hasUserState = true; + persistSidebarTreeViewState('pagetree'); + } else if (nodeId && options && options.persist === false) { + var pageStateNoPersist = sidebarTreeViewStateFor('pagetree'); + pageStateNoPersist.expandedIds.add(nodeId); + pageStateNoPersist.hasUserState = true; + } + row.setAttribute('data-page-tree-children-loading', 'true'); + try { + var projection = await fetchPageTreeProjection(relativePath); + if (!projection) return false; + var items = projectionItems(projection).filter(function(item) { + return String(item.rowKind || 'document') === 'document'; + }); + // Drop self row if scope returns the page at this folder. + var selfId = String(row.getAttribute('data-node-id') || '').trim(); + items = items.filter(function(item) { + return nodeIdOf(item) !== selfId; + }); + var patched = patchPageTreeParentChildren(row, button, items, options); + // After one shallow level is painted, cascade restore deeper expandedIds. + if (patched && !(options && options.skipCascadeRestore)) { + scheduleRestorePersistedPageTreeExpansionState(); + } + return patched; + } catch (error) { + setTreeLiveApplyError(error && error.message ? error.message : '页面树子节点加载失败'); + if (children) children.classList.add('tree-children--collapsed'); + setTreeRowExpanded(row, button, false, options); + return false; + } finally { + row.removeAttribute('data-page-tree-children-loading'); + syncPageTreeExpandVisualState(); + } + } + function toggleChildren(row, button) { var li = row && row.parentElement; var children = li ? li.querySelector(':scope > .tree-children') : null; + var shellMode = row && row.getAttribute('data-shell-mode') || ''; + if (shellMode === 'page') { + // Use visual open state (children not collapsed), not aria alone. + // Otherwise a desynced "arrow open / children hidden" first click collapses. + if (pageTreeRowIsOpen(row)) { + if (children) children.classList.add('tree-children--collapsed'); + setTreeRowExpanded(row, button, false); + return; + } + // SSR / previous expand already has child rows: expand immediately. + if (children && children.children.length > 0) { + row.setAttribute('data-page-tree-children-loaded', 'true'); + children.classList.remove('tree-children--collapsed'); + setTreeRowExpanded(row, button, true); + return; + } + if (children && row.getAttribute('data-page-tree-children-loaded') === 'true') { + children.classList.remove('tree-children--collapsed'); + setTreeRowExpanded(row, button, true); + return; + } + void loadPageTreeChildren(row, button); + return; + } if (!children) { void loadFileTreeChildren(row, button); return; @@ -1892,9 +2386,116 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { return restored; } + // Sidex-style: after load/setInput, expand nodes listed in view-state. + // PageTree shallow SSR may already contain children (open immediately) or need + // lazy children fetch for persisted expandedIds without nested rows. + var pageTreeExpansionRestoreTimer = 0; + var pageTreeExpansionRestoreQueued = false; + var PAGE_TREE_RESTORE_MAX = 24; + + function scheduleRestorePersistedPageTreeExpansionState() { + if (pageTreeExpansionRestoreQueued) return; + pageTreeExpansionRestoreQueued = true; + var run = function() { + pageTreeExpansionRestoreQueued = false; + pageTreeExpansionRestoreTimer = 0; + restorePersistedPageTreeExpansionState(); + }; + if (typeof window.requestAnimationFrame === 'function') { + pageTreeExpansionRestoreTimer = window.requestAnimationFrame(run); + } else { + pageTreeExpansionRestoreTimer = window.setTimeout(run, 0); + } + } + + function restorePersistedPageTreeExpansionState() { + var state = sidebarTreeViewStateFor('pagetree'); + if (!state || !state.hasUserState || !state.expandedIds.size) { + // No user expands: force icons to match collapsed SSR children after refresh. + syncPageTreeExpandVisualState(); + return false; + } + var restored = 0; + var pendingLoads = []; + document.querySelectorAll('#sidebar-tree-root .tree-row[data-shell-mode="page"]').forEach(function(row) { + if (!(row instanceof HTMLElement)) return; + if (restored >= PAGE_TREE_RESTORE_MAX) return; + var nodeId = String(row.getAttribute('data-node-id') || '').trim(); + if (!nodeId || !state.expandedIds.has(nodeId)) return; + var button = row.querySelector('[data-rust-action="toggle"]'); + var children = pageTreeChildrenContainer(row); + if (!button || !(children instanceof HTMLElement)) return; + if (pageTreeRowIsOpen(row)) { + restored += 1; + return; + } + // Shallow SSR already nested child rows: open immediately (icon + body). + if (children.children.length > 0) { + row.setAttribute('data-page-tree-children-loaded', 'true'); + setTreeRowExpanded(row, button, true, { persist: false }); + restored += 1; + return; + } + // Empty body: do not fake-open the chevron; hydrate then open (icon follows). + if (row.getAttribute('data-page-tree-children-loading') === 'true') return; + // Scope already returned empty for this row — skip infinite restore loops. + if (row.getAttribute('data-page-tree-restore-empty') === 'true') return; + if (currentSourceKind() === 'local_folder') { + pendingLoads.push({ row: row, button: button }); + restored += 1; + } + }); + pendingLoads.slice(0, 6).forEach(function(item) { + // Cascade further levels after each hydrate paints its children. + void loadPageTreeChildren(item.row, item.button, { + persist: false, + skipCascadeRestore: false + }); + }); + if (restored) { + document.documentElement.setAttribute('data-mnote-page-tree-expansion-restored', String(restored)); + } + // After open/hydrate schedule: icons follow visible children only. + syncPageTreeExpandVisualState(); + // If only shallow levels were opened from SSR children, schedule another + // pass so nested expandedIds under newly-visible rows get hydrated. + if (restored > 0 && pendingLoads.length === 0) { + // Children already in DOM — nested empty expanded rows need a follow-up. + var needsMore = false; + state.expandedIds.forEach(function(id) { + if (needsMore) return; + var nested = document.querySelector( + '#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="' + + String(id).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + + '"]' + ); + if (!(nested instanceof HTMLElement)) return; + if (pageTreeRowIsOpen(nested)) return; + var nestedChildren = pageTreeChildrenContainer(nested); + if (nestedChildren && nestedChildren.children.length === 0) needsMore = true; + }); + if (needsMore) scheduleRestorePersistedPageTreeExpansionState(); + } + return restored > 0; + } function installTreeLiveApplyEventListeners() { + // Load local view-state, apply to SSR DOM, then reconcile icons (Sidex setInput). sidebarTreeViewStateFor('pagetree'); + applyPageTreeExpansionState(sidebarTreeViewStateFor('pagetree')); + scheduleRestorePersistedPageTreeExpansionState(); + syncPageTreeExpandVisualState(); + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(function() { + restorePersistedPageTreeExpansionState(); + syncPageTreeExpandVisualState(); + }); + } else { + window.setTimeout(function() { + restorePersistedPageTreeExpansionState(); + syncPageTreeExpandVisualState(); + }, 0); + } document.addEventListener('visibilitychange', function() { if (!document.hidden) return; flushPendingSidebarTreeViewState('filetree'); @@ -1919,7 +2520,6 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { } } updateTitleEverywhere(detail.documentId, detail.title); - if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot(); document.documentElement.setAttribute('data-mnote-title-local-applied', 'true'); }); @@ -2054,6 +2654,8 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { installTreeLiveApplyEventListeners, isFileTreePageRow, applyLocalFolderWatchBatch, + hydratePendingFileTreeShell, + isPendingFileTreeShell, localCommandNeedsProjectionRefresh, normalizeFileTreePageRenameTitle, objectIdentityAttr, @@ -2064,6 +2666,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => { renderSidebarSnapshot, revealFileTreeResource, restorePersistedFileTreeExpansionState, + restorePersistedPageTreeExpansionState, setTreeLiveApplyError, startLocalFolderSidebarWatch, toggleChildren, diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index f9cc6829..7185d021 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -464,6 +464,149 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return true; } + /** + * Soft-open password vault: swap main content only, keep sidebar PageTree/FileTree DOM. + * Full /vault navigation re-SSRs the tree and drops expand state — avoid that on left-rail click. + */ + function ensureVaultWorkbenchRuntimeLoaded() { + if (window.mnote && window.mnote.vaultWorkbench && typeof window.mnote.vaultWorkbench.boot === 'function') { + return Promise.resolve(window.mnote.vaultWorkbench); + } + return new Promise(function (resolve, reject) { + var existing = document.querySelector('script[data-mnote-vault-runtime="1"]'); + if (existing) { + var onLoad = function () { + existing.removeEventListener('load', onLoad); + existing.removeEventListener('error', onError); + if (window.mnote && window.mnote.vaultWorkbench) resolve(window.mnote.vaultWorkbench); + else reject(new Error('vault_workbench_runtime_missing')); + }; + var onError = function () { + existing.removeEventListener('load', onLoad); + existing.removeEventListener('error', onError); + reject(new Error('vault_workbench_runtime_load_failed')); + }; + existing.addEventListener('load', onLoad); + existing.addEventListener('error', onError); + return; + } + var script = document.createElement('script'); + script.src = '/api/mnote-browser-runtime/vault-workbench-runtime.js'; + script.defer = true; + script.setAttribute('data-mnote-vault-runtime', '1'); + script.onload = function () { + if (window.mnote && window.mnote.vaultWorkbench) resolve(window.mnote.vaultWorkbench); + else reject(new Error('vault_workbench_runtime_missing')); + }; + script.onerror = function () { + reject(new Error('vault_workbench_runtime_load_failed')); + }; + document.head.appendChild(script); + }); + } + + function buildVaultWorkbenchShellHtml(workspaceId, rootUri) { + var ws = escapeHtml(String(workspaceId || '').trim()); + var root = escapeHtml(String(rootUri || '').trim()); + return ( + '
    ' + + '
    ' + + '
    ' + + '

    密码箱

    ' + + '

    ' + + '
    ' + + '
    ' + + '' + + '' + + '' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    ' + + '
    ' + ); + } + + function markVaultNavActive() { + document.querySelectorAll('.mnote-sidebar-nav a.active, .wolai-quick-actions a.active').forEach(function (link) { + link.classList.remove('active'); + }); + document + .querySelectorAll('a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"]') + .forEach(function (link) { + link.classList.add('active'); + }); + } + + async function openVaultWorkbenchSoft(targetUrl, options) { + var url = + targetUrl instanceof URL + ? targetUrl + : new URL(String(targetUrl || '/vault'), window.location.origin); + if (url.pathname !== '/vault') { + window.location.assign(url.pathname + url.search + url.hash); + return false; + } + copyWorkspaceSourceParams(url); + if (!url.searchParams.get('sourceKind') && (url.searchParams.get('rootUri') || currentRootUri())) { + url.searchParams.set('sourceKind', 'local_folder'); + } + if (!url.searchParams.get('rootUri')) { + var bodyRoot = currentRootUri(); + if (bodyRoot) url.searchParams.set('rootUri', bodyRoot); + } + var rootUri = (url.searchParams.get('rootUri') || currentRootUri() || '').trim(); + var workspaceId = ( + currentWorkspaceId() || + resolveWorkspaceId(document.body) || + (document.getElementById('sidebar-tree-root') && + document.getElementById('sidebar-tree-root').getAttribute('data-workspace-id')) || + '' + ).trim(); + var content = document.querySelector('article.mnote-content'); + if (!(content instanceof HTMLElement)) { + window.location.assign(url.pathname + url.search + url.hash); + return false; + } + var nextHref = url.pathname + url.search + url.hash; + if (mnoteNavigationInFlight === 'vault:' + nextHref) return true; + mnoteNavigationInFlight = 'vault:' + nextHref; + try { + // Preserve #sidebar-tree-root / #sidebar-file-tree-root; only replace main pane. + content.innerHTML = buildVaultWorkbenchShellHtml(workspaceId, rootUri); + if (document.body instanceof HTMLElement) { + document.body.setAttribute('data-mnote-page', 'vault'); + if (rootUri) document.body.setAttribute('data-mnote-root-uri', rootUri); + document.body.setAttribute('data-mnote-source-kind', 'local_folder'); + } + document.title = '密码箱'; + markVaultNavActive(); + if (!(options && options.replaceUrl === false)) { + window.history.pushState({ mnoteSoftNav: 'vault' }, '', nextHref); + } + var vaultApi = await ensureVaultWorkbenchRuntimeLoaded(); + if (vaultApi && typeof vaultApi.boot === 'function') vaultApi.boot(); + return true; + } catch (error) { + console.warn('[mnote vault] soft open failed, falling back to full navigation', error); + window.location.assign(nextHref); + return false; + } finally { + if (mnoteNavigationInFlight === 'vault:' + nextHref) mnoteNavigationInFlight = ''; + } + } + window.__mnoteOpenVaultWorkbench = openVaultWorkbenchSoft; + function openCurrentNavigationPage(trigger) { if (currentSourceKind() === 'local_folder') { var rootUri = currentRootUri(); @@ -3387,6 +3530,34 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim return; } + var vaultNavLink = closestAction( + e.target, + 'a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]' + ); + if (vaultNavLink instanceof HTMLAnchorElement) { + // Modifier / middle-click: keep native full navigation (new tab etc.). + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + try { + var enrichUrl = new URL(vaultNavLink.getAttribute('href') || '/vault', window.location.origin); + if (enrichUrl.pathname === '/vault') { + copyWorkspaceSourceParams(enrichUrl); + vaultNavLink.setAttribute('href', enrichUrl.pathname + enrichUrl.search + enrichUrl.hash); + } + } catch (_) {} + } else { + try { + var vaultUrl = new URL(vaultNavLink.getAttribute('href') || '/vault', window.location.origin); + if (vaultUrl.pathname === '/vault') { + e.preventDefault(); + copyWorkspaceSourceParams(vaultUrl); + vaultNavLink.setAttribute('href', vaultUrl.pathname + vaultUrl.search + vaultUrl.hash); + void openVaultWorkbenchSoft(vaultUrl); + return; + } + } catch (_) {} + } + } + var localFolderTrigger = closestAction(e.target, '[data-mnote-action="open-local-folder"]'); if (localFolderTrigger) { e.preventDefault(); @@ -3908,6 +4079,15 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim sidebarFileTreeSelection.focusedRowId = rowId; }); syncSidebarFileTreeSelection(); + // Shell-first home: FileTree may be a pending placeholder; hydrate after first paint. + if (typeof sidebarTreeLiveApply.hydratePendingFileTreeShell === 'function') { + var schedulePendingHydrate = typeof queueMicrotask === 'function' + ? queueMicrotask + : function(fn) { setTimeout(fn, 0); }; + schedulePendingHydrate(function() { + void sidebarTreeLiveApply.hydratePendingFileTreeShell('boot'); + }); + } schedulePendingLocalFolderRestoreFocus(); var recentPageOptionsApply = { documentId: '', at: 0 }; function applyPrimaryPageOptionsOnce(documentId) { diff --git a/rust/crates/mnote-web/browser/vault-workbench-runtime.js b/rust/crates/mnote-web/browser/vault-workbench-runtime.js new file mode 100644 index 00000000..14e5c916 --- /dev/null +++ b/rust/crates/mnote-web/browser/vault-workbench-runtime.js @@ -0,0 +1,1733 @@ +// Password vault workbench runtime — dedicated /vault CRUD UI. +// Refresh model: command-result re-fetch only; no setInterval. +(function initVaultWorkbenchRuntime() { + if (window.__mnoteVaultWorkbenchStarted) return; + window.__mnoteVaultWorkbenchStarted = true; + + function qs(sel, root) { + return (root || document).querySelector(sel); + } + function qsa(sel, root) { + return Array.prototype.slice.call((root || document).querySelectorAll(sel)); + } + + function toast(message, kind) { + if (window.mnote && typeof window.mnote.toast === 'function') { + window.mnote.toast(message, { kind: kind || 'info' }); + return; + } + console.log('[vault]', kind || 'info', message); + } + + function readRootUri(root) { + var fromData = root.getAttribute('data-root-uri') || ''; + if (fromData) return fromData; + var body = document.body && document.body.getAttribute('data-mnote-root-uri'); + return body || ''; + } + + function apiUrl(path, rootUri, extra) { + var url = new URL(path, window.location.origin); + if (rootUri) url.searchParams.set('rootUri', rootUri); + url.searchParams.set('sourceKind', 'local_folder'); + if (extra) { + Object.keys(extra).forEach(function (key) { + if (extra[key] != null && extra[key] !== '') { + url.searchParams.set(key, extra[key]); + } + }); + } + return url.toString(); + } + + async function apiJson(method, path, rootUri, body, queryExtra) { + var opts = { + method: method, + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }; + var url = path; + if (method === 'GET' || method === 'DELETE') { + url = apiUrl(path, rootUri, queryExtra); + } else { + opts.headers['Content-Type'] = 'application/json'; + var payload = Object.assign({ rootUri: rootUri, sourceKind: 'local_folder' }, body || {}); + opts.body = JSON.stringify(payload); + if (queryExtra) { + url = apiUrl(path, rootUri, queryExtra); + } + } + var res = await fetch(url, opts); + var data = null; + try { + data = await res.json(); + } catch (_e) { + data = null; + } + if (!res.ok || (data && data.ok === false)) { + var code = (data && (data.code || (data.error && data.error.code))) || res.status; + var msg = + (data && (data.message || data.error || (data.error && data.error.message))) || + ('请求失败 ' + res.status); + if (typeof msg === 'object') msg = JSON.stringify(msg); + var err = new Error(String(msg)); + err.code = code; + err.status = res.status; + err.payload = data; + throw err; + } + return data && data.result != null ? data.result : data; + } + + function maskLabel(state) { + if (!state || state.state === 'absent') return '(无)'; + if (state.state === 'masked') return '••••••••'; + if (state.state === 'revealed') return state.value || ''; + return '••••••••'; + } + + function escapeHtml(text) { + return String(text == null ? '' : text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function bindWorkbench(root) { + if (!(root instanceof HTMLElement) || root.getAttribute('data-vault-bound') === '1') { + return; + } + root.setAttribute('data-vault-bound', '1'); + + var state = { + rootUri: readRootUri(root), + status: root.getAttribute('data-status') || 'active', + items: [], + selectedId: root.getAttribute('data-selected-id') || '', + item: null, + dirty: false, + revealed: { password: false, apikey: false, token: false }, + secretDraft: { password: null, apikey: null, token: null }, + mode: 'view', // view | create | edit | cipher + /** @type {Record} collapsed folder paths (true = collapsed) */ + collapsedFolders: {}, + cipherKeys: [], + cipherRevealed: {}, + isAiVault: false, + aiVaultActorId: 'mnote-e2e', + vaultRole: 'user', + showEmptyFields: false, + }; + + var listEl = qs('[data-vault-list]', root); + var detailEl = qs('[data-vault-detail]', root); + var statusEl = qs('[data-vault-status]', root); + var searchEl = qs('[data-vault-search]', root); + var tabActive = qs('[data-vault-tab="active"]', root); + var tabDeleted = qs('[data-vault-tab="deleted"]', root); + + try { + var savedCollapse = sessionStorage.getItem('mnote.vault.collapsedFolders'); + if (savedCollapse) state.collapsedFolders = JSON.parse(savedCollapse) || {}; + } catch (_e) { + state.collapsedFolders = {}; + } + + function persistCollapse() { + try { + sessionStorage.setItem( + 'mnote.vault.collapsedFolders', + JSON.stringify(state.collapsedFolders) + ); + } catch (_e) { + /* ignore */ + } + } + + /** Normalize folderPath for save; groups appear in the list only after an entry uses them. */ + function normalizeFolderPath(raw) { + var fp = String(raw || '') + .trim() + .replace(/\\/g, '/') + .replace(/\/+/g, '/') + .replace(/^\/+|\/+$/g, ''); + if (!fp) return ''; + var parts = fp.split('/').filter(function (p) { + return p && p !== '.' && p !== '..'; + }); + return parts.join('/'); + } + + function isNotesEmpty(notes) { + var t = String(notes || '') + .replace(/^#+\s*Notes\s*/i, '') + .trim(); + return !t; + } + + function itemIsSharedToAi(item) { + return !!(item && (item.isSharedToAi || item.sharedToAi)); + } + + function itemIsAiCopy(item) { + return !!(item && (item.isAiSharedCopy || item.sharedFrom || + ((item.tags || []).indexOf('ai-shared') >= 0))); + } + + function updateHeaderChrome() { + var titleEl = qs('.mnote-vault-header h1', root); + var badge = qs('[data-vault-role-badge]', root); + if (titleEl) { + titleEl.textContent = state.isAiVault ? 'AI 密码本' : '密码箱'; + } + if (!badge) { + var main = qs('.mnote-vault-header-main', root); + if (main) { + badge = document.createElement('span'); + badge.setAttribute('data-vault-role-badge', '1'); + badge.className = 'mnote-vault-role-badge'; + main.appendChild(badge); + } + } + if (badge) { + if (state.isAiVault) { + badge.hidden = false; + badge.textContent = 'AI 专用 · ' + (state.aiVaultActorId || 'mnote-e2e'); + badge.classList.add('is-ai'); + } else { + badge.hidden = true; + badge.classList.remove('is-ai'); + } + } + root.setAttribute('data-vault-role', state.isAiVault ? 'ai' : 'user'); + } + + function setStatus(message, type) { + if (!statusEl) return; + statusEl.textContent = message || ''; + statusEl.setAttribute('data-type', type || 'info'); + } + + function confirmDiscard() { + if (!state.dirty) return true; + return window.confirm('有未保存的修改,确定放弃?'); + } + + function filteredItems() { + var q = searchEl && searchEl.value ? String(searchEl.value).trim().toLowerCase() : ''; + if (!q) return state.items.slice(); + return state.items.filter(function (item) { + var hay = [ + item.title, + item.url, + item.username, + item.email, + item.passwordHint, + item.folderPath, + (item.tags || []).join(' '), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return hay.indexOf(q) >= 0; + }); + } + + /** Build nested folder tree from folderPath (a/b/c). Only paths that have entries. */ + function buildFolderTree(items) { + var treeRoot = { name: '', path: '', children: {}, items: [] }; + items.forEach(function (item) { + var fp = (item.folderPath || '').trim(); + if (!fp) { + treeRoot.items.push(item); + return; + } + var parts = fp.split('/').filter(Boolean); + var node = treeRoot; + var acc = []; + parts.forEach(function (part) { + acc.push(part); + var p = acc.join('/'); + if (!node.children[part]) { + node.children[part] = { name: part, path: p, children: {}, items: [] }; + } + node = node.children[part]; + }); + node.items.push(item); + }); + return treeRoot; + } + + function renderItemButton(item) { + var active = item.id === state.selectedId ? ' is-active' : ''; + var meta = [item.username || item.email || ''] + .filter(Boolean) + .join(' · '); + // Single-row compact: title · meta | status glyphs only (no badge rows). + var icons = ''; + if (itemIsSharedToAi(item) && !state.isAiVault) { + icons += + ''; + } + if (itemIsAiCopy(item)) { + icons += + ''; + } + return ( + '' + ); + } + + function countNodeItems(node) { + var n = node.items.length; + Object.keys(node.children).forEach(function (k) { + n += countNodeItems(node.children[k]); + }); + return n; + } + + /** Group items sharing the same non-empty URL into a collapsible site node. */ + function renderItemsWithUrlGroups(items, depth, folderPath) { + var html = ''; + var byUrl = {}; + var noUrl = []; + items.forEach(function (item) { + var u = (item.url || '').trim(); + if (!u) { + noUrl.push(item); + return; + } + if (!byUrl[u]) byUrl[u] = []; + byUrl[u].push(item); + }); + // Collapsible site group only when 2+ accounts share the URL. + Object.keys(byUrl) + .sort(function (a, b) { + return a.localeCompare(b, 'zh'); + }) + .forEach(function (url) { + var group = byUrl[url]; + if (group.length < 2) { + noUrl.push(group[0]); + return; + } + var siteKey = 'url:' + (folderPath || '') + '|' + url; + var collapsed = !!state.collapsedFolders[siteKey]; + var label = shortenUrlLabel(url); + html += + '
    ' + + ''; + if (!collapsed) { + html += '
    '; + group + .slice() + .sort(function (a, b) { + var ua = String(a.username || a.title || ''); + var ub = String(b.username || b.title || ''); + return ua.localeCompare(ub, 'zh'); + }) + .forEach(function (item) { + html += + '
    ' + + renderItemButton(item) + + '
    '; + }); + html += '
    '; + } + html += '
    '; + }); + noUrl + .slice() + .sort(function (a, b) { + return String(a.title || '').localeCompare(String(b.title || ''), 'zh'); + }) + .forEach(function (item) { + html += + '
    ' + + renderItemButton(item) + + '
    '; + }); + return html; + } + + function shortenUrlLabel(url) { + try { + var u = new URL(url.indexOf('://') >= 0 ? url : 'https://' + url); + return u.host + (u.pathname && u.pathname !== '/' ? u.pathname : ''); + } catch (_e) { + return url.length > 40 ? url.slice(0, 37) + '…' : url; + } + } + + function renderTreeNode(node, depth) { + var html = ''; + var childKeys = Object.keys(node.children).sort(function (a, b) { + return a.localeCompare(b, 'zh'); + }); + childKeys.forEach(function (key) { + var child = node.children[key]; + // 只有一个直连凭证的叶子分组会在左栏重复同名标签,直接展平。 + if (child.items.length === 1 && Object.keys(child.children).length === 0) { + html += + '
    ' + + renderItemButton(child.items[0]) + + '
    '; + return; + } + var collapsed = !!state.collapsedFolders[child.path]; + var count = countNodeItems(child); + html += + '
    ' + + ''; + if (!collapsed) { + html += '
    '; + html += renderTreeNode(child, depth + 1); + html += renderItemsWithUrlGroups(child.items, depth + 1, child.path); + html += '
    '; + } + html += '
    '; + }); + return html; + } + + function renderList() { + if (!listEl) return; + var items = filteredItems(); + if (!items.length) { + listEl.innerHTML = + '
    暂无条目。点击「新建」添加。
    '; + return; + } + var tree = buildFolderTree(items); + var html = ''; + // Root (ungrouped) items first — also group by same URL + if (tree.items.length) { + html += + '
    ' + + '
    未分组
    ' + + '
    ' + + renderItemsWithUrlGroups(tree.items, 0, '') + + '
    '; + } + html += renderTreeNode(tree, 0); + listEl.innerHTML = html; + } + + function emptyForm(defaults) { + defaults = defaults || {}; + return { + id: '', + title: defaults.title || '', + url: defaults.url || '', + username: defaults.username || '', + email: defaults.email || '', + passwordHint: defaults.passwordHint || '', + folderPath: defaults.folderPath || '', + password: { state: 'absent' }, + apikey: { state: 'absent' }, + token: { state: 'absent' }, + tags: defaults.tags || [], + notesMarkdown: defaults.notesMarkdown || '## Notes\n\n', + revision: 0, + status: 'active', + }; + } + + function renderDetail() { + if (!detailEl) return; + if (!state.rootUri) { + detailEl.innerHTML = + '
    请先打开本地文件夹工作区,再使用密码箱。
    '; + return; + } + if (state.mode === 'cipher') { + renderCipherBook(); + return; + } + if (state.mode === 'create') { + renderForm(emptyForm(state._createDefaults || {}), true); + return; + } + if (!state.item && !state.selectedId) { + detailEl.innerHTML = + '
    从左侧选择条目,或点击「新建」。
    '; + return; + } + if (!state.item) { + detailEl.innerHTML = '
    加载中…
    '; + return; + } + if (state.mode === 'edit') { + renderForm(state.item, false); + return; + } + renderView(state.item); + } + + function secretRow(label, field, secretState, showEmpty) { + var has = secretState && secretState.state === 'masked'; + var canReveal = has || (secretState && secretState.state === 'revealed'); + if (!canReveal && !showEmpty) return ''; + var shown = state.revealed[field] + ? state._revealedValues && state._revealedValues[field] + ? escapeHtml(state._revealedValues[field]) + : '…' + : maskLabel(secretState); + return ( + '
    ' + + '' + + '
    ' + + shown + + '
    ' + + '
    ' + + (canReveal + ? '' + + '' + : '未设置') + + '
    ' + ); + } + + function sameUrlAccounts(item) { + var url = (item.url || '').trim(); + if (!url) return []; + return state.items.filter(function (other) { + return other.id !== item.id && (other.url || '').trim() === url; + }); + } + + function renderView(item) { + var visibleTags = (item.tags || []).filter(function (t) { + return t !== 'ai-shared'; + }); + var tags = visibleTags + .map(function (t) { + return '' + escapeHtml(t) + ''; + }) + .join(' '); + var isDeleted = item.status === 'deleted' || state.status === 'deleted'; + var siblings = sameUrlAccounts(item); + var siblingHtml = ''; + if (siblings.length) { + siblingHtml = + '
    ' + + siblings + .map(function (s) { + return ( + '' + ); + }) + .join('') + + '
    '; + } + var shared = itemIsSharedToAi(item); + var aiCopy = itemIsAiCopy(item) || state.isAiVault; + var link = item.sharedToAi || {}; + var titleChips = ''; + if (shared && !state.isAiVault) { + titleChips += + '已共享'; + } + if (aiCopy && (item.sharedFrom || state.isAiVault)) { + titleChips += + 'AI'; + } + + var shareBtn = ''; + if (!isDeleted && !state.isAiVault && !aiCopy) { + shareBtn = shared + ? '' + + '' + : ''; + } + + var showEmpty = !!state.showEmptyFields; + var fieldsHtml = + fieldRow('分组', item.folderPath, showEmpty) + + fieldRow('网址', item.url, showEmpty) + + fieldRow('用户名', item.username, showEmpty) + + fieldRow('邮箱', item.email, showEmpty) + + secretRow('密码', 'password', item.password, showEmpty) + + fieldRow('提示', item.passwordHint, showEmpty) + + secretRow('API Key', 'apikey', item.apikey, showEmpty) + + secretRow('Token', 'token', item.token, showEmpty) + + (visibleTags.length || showEmpty + ? '
    ' + + (tags || '') + + '
    ' + : '') + + siblingHtml + + (!isNotesEmpty(item.notesMarkdown) || showEmpty + ? '
    ' +
    +            escapeHtml(item.notesMarkdown || '') +
    +            '
    ' + : '') + + '

    ' + + '

    '; + + detailEl.innerHTML = + '
    ' + + '
    ' + + '

    ' + + '' + + escapeHtml(item.title || '') + + '' + + titleChips + + '

    ' + + '
    ' + + (isDeleted + ? '' + + '' + : '' + + shareBtn + + '' + + '') + + '
    ' + + '
    ' + + fieldsHtml + + '
    '; + } + + function fieldRow(label, value, showEmpty) { + if (!value && !showEmpty) return ''; + return ( + '
    ' + + (value + ? escapeHtml(value) + : '') + + '
    ' + ); + } + + /** Unique folderPath values from current list (for select / datalist). */ + function collectFolderPaths() { + var set = {}; + (state.items || []).forEach(function (item) { + var fp = (item.folderPath || '').trim(); + if (!fp) return; + set[fp] = true; + var parts = fp.split('/').filter(Boolean); + for (var i = 1; i < parts.length; i++) { + set[parts.slice(0, i).join('/')] = true; + } + }); + return Object.keys(set).sort(function (a, b) { + return a.localeCompare(b, 'zh'); + }); + } + + function formFolderPathField(value) { + var current = (value || '').trim(); + var paths = collectFolderPaths(); + var seen = {}; + paths.forEach(function (p) { + seen[p] = true; + }); + var options = + ''; + paths.forEach(function (p) { + options += + ''; + }); + if (current && !seen[current]) { + options += + ''; + } + return ( + '
    ' + + '' + + '
    ' + + '' + + '' + + '' + + paths + .map(function (p) { + return ''; + }) + .join('') + + '' + + '
    ' + ); + } + + function bindFolderPathControls(form) { + if (!form) return; + var select = qs('[data-vault-folder-select]', form); + var input = qs('[data-vault-folder-input]', form); + if (select && input) { + select.addEventListener('change', function () { + input.value = select.value || ''; + state.dirty = true; + }); + } + if (input) { + input.addEventListener('input', function () { + var n = normalizeFolderPath(input.value); + // keep select in sync when user picks a known path via typing + if (select) { + var match = false; + for (var i = 0; i < select.options.length; i++) { + if (select.options[i].value === n) { + select.selectedIndex = i; + match = true; + break; + } + } + if (!match) select.selectedIndex = 0; + } + }); + input.addEventListener('change', function () { + var n = normalizeFolderPath(input.value); + input.value = n; + state.dirty = true; + }); + } + } + + function renderForm(item, isCreate) { + var tagsStr = (item.tags || []).join(', '); + detailEl.innerHTML = + '
    ' + + '
    ' + + '

    ' + + (isCreate ? '新建条目' : '编辑条目') + + '

    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + formField('title', '标题 *', item.title || '', true) + + formFolderPathField(item.folderPath || '') + + formField('url', '网址', item.url || '', false) + + formField('username', '用户名', item.username || '', false) + + formField('email', '邮箱', item.email || '', false) + + formSecretField('password', '密码(可用 [Key] 密文片段)', item.password, isCreate) + + formField('passwordHint', '密码提示', item.passwordHint || '', false) + + formSecretField('apikey', 'API Key', item.apikey, isCreate) + + formSecretField('token', 'Token', item.token, isCreate) + + formField('tags', '标签(逗号分隔)', tagsStr, false) + + '
    ' + + '
    ' + + '
    '; + var form = qs('[data-vault-form]', detailEl); + if (form) { + bindFolderPathControls(form); + form.addEventListener('input', function () { + state.dirty = true; + }); + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + saveForm(form, isCreate); + }); + } + } + + function formField(name, label, value, required) { + return ( + '
    ' + ); + } + + function formSecretField(name, label, secretState, isCreate) { + var placeholder = + !isCreate && secretState && secretState.state === 'masked' + ? '留空表示不修改;输入新值覆盖;点「清空」删除' + : '可选;可用 [Key] 密文片段'; + // Editing secrets is always plaintext so users can see typos / [Key] templates. + // View mode still masks; reveal remains explicit + audited. + return ( + '
    ' + + '
    ' + + '' + + (!isCreate + ? '' + : '') + + '
    ' + ); + } + + async function loadList() { + if (!state.rootUri) { + state.items = []; + renderList(); + renderDetail(); + setStatus('未绑定本地文件夹 rootUri', 'error'); + return; + } + setStatus('加载列表…', 'info'); + try { + var result = await apiJson('GET', '/api/vault/list', state.rootUri, null, { + status: state.status, + }); + state.items = (result && result.items) || []; + state.isAiVault = !!(result && result.isAiVault); + state.aiVaultActorId = + (result && result.aiVaultActorId) || state.aiVaultActorId || 'mnote-e2e'; + state.vaultRole = (result && result.vaultRole) || (state.isAiVault ? 'ai' : 'user'); + updateHeaderChrome(); + renderList(); + if (state.selectedId) { + var still = state.items.some(function (i) { + return i.id === state.selectedId; + }); + if (!still && state.mode !== 'create') { + state.selectedId = ''; + state.item = null; + } + } + if (state.selectedId && state.mode !== 'create' && state.mode !== 'edit') { + await loadItem(state.selectedId); + } else { + renderDetail(); + } + setStatus( + (state.isAiVault ? 'AI 密码本 · ' : '') + '共 ' + state.items.length + ' 条', + 'success' + ); + } catch (err) { + setStatus(err.message || String(err), 'error'); + toast(err.message || String(err), 'error'); + } + } + + async function loadItem(id) { + try { + var result = await apiJson('GET', '/api/vault/items/' + encodeURIComponent(id), state.rootUri); + state.item = result.item || result; + state.selectedId = id; + state.mode = 'view'; + state.dirty = false; + state.revealed = { password: false, apikey: false, token: false }; + state._revealedValues = {}; + state.secretDraft = { password: null, apikey: null, token: null }; + renderList(); + renderDetail(); + } catch (err) { + setStatus(err.message || String(err), 'error'); + toast(err.message || String(err), 'error'); + } + } + + async function saveForm(form, isCreate) { + var fd = new FormData(form); + var title = String(fd.get('title') || '').trim(); + if (!title) { + toast('标题不能为空', 'error'); + return; + } + var tagsRaw = String(fd.get('tags') || ''); + var tags = tagsRaw + .split(/[,,;;\n]/) + .map(function (s) { + return s.trim(); + }) + .filter(Boolean); + var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim()); + var body = { + title: title, + url: String(fd.get('url') || '').trim() || null, + username: String(fd.get('username') || '').trim() || null, + email: String(fd.get('email') || '').trim() || null, + passwordHint: String(fd.get('passwordHint') || '').trim() || null, + folderPath: folderPath || null, + tags: tags, + notesMarkdown: String(fd.get('notesMarkdown') || ''), + syncAiShare: true, + }; + + ['password', 'apikey', 'token'].forEach(function (field) { + var input = form.querySelector('[data-secret-field="' + field + '"]'); + var cleared = state.secretDraft[field] === 'clear'; + var raw = input ? String(input.value || '') : ''; + if (isCreate) { + if (raw) body[field] = raw; + } else if (cleared) { + body[field] = { clear: true }; + } else if (raw) { + // reject mask sentinels client-side + if (/^[•*·]+$/.test(raw) || raw === '••••••••') { + toast('不能将掩码写回 ' + field, 'error'); + throw new Error('mask'); + } + body[field] = { set: raw }; + } + }); + + try { + var result; + if (isCreate) { + result = await apiJson('POST', '/api/vault/items', state.rootUri, body); + } else { + body.baseRevision = state.item && state.item.revision; + result = await apiJson( + 'PATCH', + '/api/vault/items/' + encodeURIComponent(state.selectedId), + state.rootUri, + body + ); + } + state.item = result.item || result; + state.selectedId = state.item.id; + state.mode = 'view'; + state.dirty = false; + state.secretDraft = { password: null, apikey: null, token: null }; + state._createDefaults = null; + var msg = isCreate ? '已创建' : '已保存'; + var aiSync = result && result.aiShareSync; + if (aiSync && aiSync.ok) { + msg += ' · 已同步 AI 副本'; + } else if (aiSync && aiSync.ok === false) { + msg += ' · AI 同步未完成(' + (aiSync.message || aiSync.code || '') + ')'; + } + toast(msg, aiSync && aiSync.ok === false ? 'info' : 'success'); + setStatus(msg, 'success'); + await loadList(); + await loadItem(state.selectedId); + } catch (err) { + if (err.message === 'mask') return; + setStatus(err.message || String(err), 'error'); + toast(err.message || String(err), 'error'); + } + } + + async function loadCipherBook() { + try { + var result = await apiJson('GET', '/api/vault/cipher-book', state.rootUri); + state.cipherKeys = (result && result.keys) || []; + state.cipherRevealed = {}; + renderCipherBook(); + setStatus('密文簿 ' + state.cipherKeys.length + ' 个 key', 'info'); + } catch (err) { + setStatus(err.message || String(err), 'error'); + toast(err.message || String(err), 'error'); + } + } + + function renderCipherBook() { + if (!detailEl) return; + var rows = state.cipherKeys + .map(function (k) { + var key = k.key; + var revealed = state.cipherRevealed[key]; + return ( + '
    ' + + '[' + + escapeHtml(key) + + ']' + + '' + + (revealed + ? escapeHtml(revealed) + : k.hasValue + ? '••••••••' + : '(空)') + + '' + + '' + + '' + + '' + + '' + + '
    ' + ); + }) + .join(''); + detailEl.innerHTML = + '
    ' + + '
    ' + + '

    密文簿

    ' + + '
    ' + + '' + + '
    ' + + + '
    ' + + '' + + '' + + '' + + '
    ' + + '
    ' + + (rows || + '
    尚无片段。例如 key=A 值为 Li@,密码可写 Li@[A]s3cret
    ') + + '
    '; + var form = qs('[data-vault-cipher-form]', detailEl); + if (form) { + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + var fd = new FormData(form); + var key = String(fd.get('key') || '').trim(); + var value = String(fd.get('value') || ''); + if (!key || !value) { + toast('需要 Key 与片段值', 'error'); + return; + } + apiJson( + 'PUT', + '/api/vault/cipher-book/' + encodeURIComponent(key), + state.rootUri, + { value: value } + ) + .then(function () { + toast('已保存 [' + key + ']', 'success'); + return loadCipherBook(); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + }); + } + } + + root.addEventListener('click', function (ev) { + var t = ev.target; + if (!(t instanceof Element)) return; + + var toggle = t.closest('[data-vault-toggle-folder]'); + if (toggle && root.contains(toggle)) { + var fpath = toggle.getAttribute('data-vault-toggle-folder') || ''; + state.collapsedFolders[fpath] = !state.collapsedFolders[fpath]; + persistCollapse(); + renderList(); + return; + } + + var itemBtn = t.closest('[data-vault-item-id]'); + if (itemBtn && root.contains(itemBtn)) { + var id = itemBtn.getAttribute('data-vault-item-id'); + if (!confirmDiscard()) return; + state.mode = 'view'; + loadItem(id); + return; + } + + if (t.closest('[data-vault-create], [data-vault-new]')) { + if (!confirmDiscard()) return; + state.mode = 'create'; + state.selectedId = ''; + state.item = null; + state._createDefaults = null; + state.dirty = false; + state.secretDraft = { password: null, apikey: null, token: null }; + renderList(); + renderDetail(); + return; + } + + if (t.closest('[data-vault-toggle-empty-fields]')) { + state.showEmptyFields = !state.showEmptyFields; + if (state.item && state.mode === 'view') renderView(state.item); + return; + } + + if (t.closest('[data-vault-add-account]')) { + if (!confirmDiscard()) return; + var cur = state.item || {}; + state.mode = 'create'; + state.selectedId = ''; + state.item = null; + state._createDefaults = { + title: (cur.title || '') + ' (账号)', + url: cur.url || '', + folderPath: cur.folderPath || '', + tags: (cur.tags || []).slice(), + email: '', + username: '', + }; + state.dirty = false; + state.secretDraft = { password: null, apikey: null, token: null }; + renderList(); + renderDetail(); + return; + } + + if (t.closest('[data-vault-share-ai]')) { + if (!state.selectedId || !state.rootUri) return; + if (state.isAiVault) { + toast('当前已是 AI 密码本,无需再共享', 'info'); + return; + } + var shareTitle = + (state.item && state.item.title) || state.selectedId; + var alreadyShared = itemIsSharedToAi(state.item); + if ( + !window.confirm( + alreadyShared + ? '将「' + + shareTitle + + '」重新同步到已关联的 AI 密码本副本?\n' + + '会更新目标条目内容与缺失密文 key(目标已有 key 不覆盖)。' + : '将「' + + shareTitle + + '」复制到 AI 密码本(默认账号 mnote-e2e 的 my-space vault)?\n' + + '会新建一条带 ai-shared 标签的副本,并在本条目标记「已共享」;原条目保留。\n' + + '密文 [Key] 模板原样复制,并自动同步缺失密文 key。' + ) + ) { + return; + } + apiJson( + 'POST', + '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/share-to-ai', + state.rootUri, + { syncCipherKeys: true } + ) + .then(function (result) { + var target = (result && result.targetActorId) || 'mnote-e2e'; + var newId = result && result.item && result.item.id; + var cipher = (result && result.cipherSync) || {}; + var copied = cipher.copiedKeys || cipher.copied || []; + var missing = cipher.missingInSource || []; + var parts = [ + result && result.updatedExisting + ? '已同步 AI 副本(' + target + ')' + : '已共享到 AI 密码本(' + target + ')', + ]; + if (newId) parts.push(newId); + if (copied.length) parts.push('同步密文 key: ' + copied.join(', ')); + if (missing.length) { + parts.push('源缺 key: ' + missing.join(', ')); + } + toast(parts.join(' · '), missing.length ? 'info' : 'success'); + setStatus(parts.join(' · '), missing.length ? 'info' : 'success'); + // Refresh source item so shared banner / button label update + return loadItem(state.selectedId).then(function () { + return loadList(); + }); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + setStatus(err.message || String(err), 'error'); + }); + return; + } + + if (t.closest('[data-vault-unshare-ai]')) { + if (!state.selectedId || !state.rootUri) return; + if (state.isAiVault) { + toast('请在用户密码箱中撤销共享', 'info'); + return; + } + if (!itemIsSharedToAi(state.item)) { + toast('当前条目未共享到 AI', 'info'); + return; + } + var unshareTitle = + (state.item && state.item.title) || state.selectedId; + var linkActor = + (state.item && + state.item.sharedToAi && + state.item.sharedToAi.targetActorId) || + 'mnote-e2e'; + if ( + !window.confirm( + '撤销「' + + unshareTitle + + '」对 AI 密码本(' + + linkActor + + ')的共享?\n' + + '将清除本条「已共享」标记,并把 AI 密码本中的副本移入回收站。\n' + + '原条目与密文簿 key 保留;AI 侧密文 key 不删除(可能被其它条目引用)。' + ) + ) { + return; + } + apiJson( + 'POST', + '/api/vault/items/' + + encodeURIComponent(state.selectedId) + + '/unshare-from-ai', + state.rootUri, + {} + ) + .then(function (result) { + var target = (result && result.targetActorId) || linkActor; + var parts = []; + if (result && result.wasShared === false) { + parts.push('本来就未共享'); + } else { + parts.push('已撤销共享(' + target + ')'); + if (result && result.targetDeleted) { + parts.push('AI 副本已移入回收站'); + } else if (result && result.removedTargetId) { + parts.push('AI 副本已不存在或已在回收站'); + } + } + toast(parts.join(' · '), 'success'); + setStatus(parts.join(' · '), 'success'); + return loadItem(state.selectedId).then(function () { + return loadList(); + }); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + setStatus(err.message || String(err), 'error'); + }); + return; + } + + if (t.closest('[data-vault-cipher-book]')) { + if (!confirmDiscard()) return; + state.mode = 'cipher'; + state.dirty = false; + loadCipherBook(); + return; + } + if (t.closest('[data-vault-cipher-close]')) { + state.mode = state.selectedId ? 'view' : 'view'; + if (state.selectedId) loadItem(state.selectedId); + else { + state.item = null; + renderDetail(); + } + return; + } + var cipherReveal = t.closest('[data-vault-cipher-reveal]'); + if (cipherReveal) { + var ck = cipherReveal.getAttribute('data-vault-cipher-reveal'); + if (state.cipherRevealed[ck]) { + delete state.cipherRevealed[ck]; + renderCipherBook(); + return; + } + apiJson( + 'POST', + '/api/vault/cipher-book/' + encodeURIComponent(ck) + '/reveal', + state.rootUri, + {} + ) + .then(function (result) { + var secret = result && result.secret; + if (secret && secret.state === 'revealed') { + state.cipherRevealed[ck] = secret.value; + renderCipherBook(); + } + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + return; + } + var cipherEdit = t.closest('[data-vault-cipher-edit]'); + if (cipherEdit) { + var ek = cipherEdit.getAttribute('data-vault-cipher-edit'); + var nv = window.prompt('更新 [' + ek + '] 的片段值(输入明文)', ''); + if (nv == null || nv === '') return; + apiJson( + 'PUT', + '/api/vault/cipher-book/' + encodeURIComponent(ek), + state.rootUri, + { value: nv } + ) + .then(function () { + toast('已更新 [' + ek + ']', 'success'); + delete state.cipherRevealed[ek]; + return loadCipherBook(); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + return; + } + var cipherDel = t.closest('[data-vault-cipher-delete]'); + if (cipherDel) { + var dk = cipherDel.getAttribute('data-vault-cipher-delete'); + if (!window.confirm('删除密文簿 [' + dk + ']?引用它的密码将无法完整展开。')) return; + apiJson( + 'DELETE', + '/api/vault/cipher-book/' + encodeURIComponent(dk), + state.rootUri + ) + .then(function () { + toast('已删除 [' + dk + ']', 'success'); + delete state.cipherRevealed[dk]; + return loadCipherBook(); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + return; + } + + if (t.closest('[data-vault-refresh]')) { + if (!confirmDiscard()) return; + state.mode = 'view'; + state.dirty = false; + loadList(); + return; + } + + if (t.closest('[data-vault-tab="active"]')) { + if (!confirmDiscard()) return; + state.status = 'active'; + state.mode = 'view'; + state.selectedId = ''; + state.item = null; + if (tabActive) tabActive.classList.add('is-active'); + if (tabDeleted) tabDeleted.classList.remove('is-active'); + root.setAttribute('data-status', 'active'); + loadList(); + return; + } + if (t.closest('[data-vault-tab="deleted"]')) { + if (!confirmDiscard()) return; + state.status = 'deleted'; + state.mode = 'view'; + state.selectedId = ''; + state.item = null; + if (tabDeleted) tabDeleted.classList.add('is-active'); + if (tabActive) tabActive.classList.remove('is-active'); + root.setAttribute('data-status', 'deleted'); + loadList(); + return; + } + + if (t.closest('[data-vault-edit]')) { + state.mode = 'edit'; + state.dirty = false; + state.secretDraft = { password: null, apikey: null, token: null }; + renderDetail(); + return; + } + if (t.closest('[data-vault-cancel]')) { + if (!confirmDiscard()) return; + state.mode = state.selectedId ? 'view' : 'view'; + state.dirty = false; + if (state.selectedId) loadItem(state.selectedId); + else { + state.item = null; + renderDetail(); + } + return; + } + + var clearBtn = t.closest('[data-vault-clear-secret]'); + if (clearBtn) { + var cf = clearBtn.getAttribute('data-vault-clear-secret'); + state.secretDraft[cf] = 'clear'; + state.dirty = true; + var input = detailEl.querySelector('[data-secret-field="' + cf + '"]'); + if (input) { + input.value = ''; + input.placeholder = '(将清空)'; + } + toast(cf + ' 将在保存时清空', 'info'); + return; + } + + if (t.closest('[data-vault-delete]')) { + if (!window.confirm('确定将条目移入密码箱回收站?')) return; + apiJson( + 'DELETE', + '/api/vault/items/' + encodeURIComponent(state.selectedId), + state.rootUri + ) + .then(function () { + toast('已移入回收站', 'success'); + state.selectedId = ''; + state.item = null; + return loadList(); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + return; + } + if (t.closest('[data-vault-restore]')) { + apiJson( + 'POST', + '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/restore', + state.rootUri, + {} + ) + .then(function () { + toast('已恢复', 'success'); + state.status = 'active'; + if (tabActive) tabActive.classList.add('is-active'); + if (tabDeleted) tabDeleted.classList.remove('is-active'); + return loadList(); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + return; + } + if (t.closest('[data-vault-purge]')) { + if (!window.confirm('永久删除后无法恢复,确定?')) return; + apiJson( + 'POST', + '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/purge', + state.rootUri, + {} + ) + .then(function () { + toast('已永久删除', 'success'); + state.selectedId = ''; + state.item = null; + return loadList(); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + return; + } + + var revealBtn = t.closest('[data-vault-reveal]'); + if (revealBtn) { + var field = revealBtn.getAttribute('data-vault-reveal'); + if (state.revealed[field]) { + state.revealed[field] = false; + renderDetail(); + return; + } + apiJson( + 'POST', + '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal', + state.rootUri, + { field: field } + ) + .then(function (result) { + var secret = result && result.secret; + state._revealedValues = state._revealedValues || {}; + if (secret && secret.state === 'revealed') { + state._revealedValues[field] = secret.value || ''; + state.revealed[field] = true; + if (secret.missingCipherKeys && secret.missingCipherKeys.length) { + toast( + '密文簿缺少 key: ' + secret.missingCipherKeys.join(', '), + 'info' + ); + } + } else { + toast('该字段未设置', 'info'); + } + renderDetail(); + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + return; + } + + var copyBtn = t.closest('[data-vault-copy-secret]'); + if (copyBtn) { + var cfield = copyBtn.getAttribute('data-vault-copy-secret'); + var doCopy = function (text) { + if (!text) { + toast('无可复制内容', 'info'); + return; + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then( + function () { + toast('已复制', 'success'); + }, + function () { + toast('复制失败', 'error'); + } + ); + } else { + toast('剪贴板不可用', 'error'); + } + }; + if (state.revealed[cfield] && state._revealedValues && state._revealedValues[cfield]) { + doCopy(state._revealedValues[cfield]); + } else { + apiJson( + 'POST', + '/api/vault/items/' + encodeURIComponent(state.selectedId) + '/reveal', + state.rootUri, + { field: cfield } + ) + .then(function (result) { + var secret = result && result.secret; + if (secret && secret.state === 'revealed') { + doCopy(secret.value || ''); + } else { + toast('该字段未设置', 'info'); + } + }) + .catch(function (err) { + toast(err.message || String(err), 'error'); + }); + } + return; + } + + var copyPlain = t.closest('[data-vault-copy]'); + if (copyPlain) { + var text = copyPlain.getAttribute('data-vault-copy') || ''; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { + toast('已复制', 'success'); + }); + } + } + }); + + if (searchEl) { + searchEl.addEventListener('input', function () { + renderList(); + }); + } + + // deep-link id + try { + var params = new URLSearchParams(window.location.search); + var deepId = params.get('id'); + if (deepId) state.selectedId = deepId; + var st = params.get('status'); + if (st === 'deleted') { + state.status = 'deleted'; + if (tabDeleted) tabDeleted.classList.add('is-active'); + if (tabActive) tabActive.classList.remove('is-active'); + } + } catch (_e) {} + + // ensure then list + if (state.rootUri) { + apiJson('POST', '/api/vault/ensure', state.rootUri, {}) + .then(function () { + return loadList(); + }) + .catch(function (err) { + setStatus(err.message || String(err), 'error'); + loadList(); + }); + } else { + renderList(); + renderDetail(); + setStatus('请先打开本地文件夹', 'error'); + } + } + + /** Keep left-rail /vault links in the current local_folder context. */ + function enrichVaultNavLinks() { + var rootUri = + (document.body && document.body.getAttribute('data-mnote-root-uri')) || + new URLSearchParams(window.location.search).get('rootUri') || + ''; + rootUri = String(rootUri || '').trim(); + var sourceKind = + (document.body && document.body.getAttribute('data-mnote-source-kind')) || + new URLSearchParams(window.location.search).get('sourceKind') || + (rootUri ? 'local_folder' : ''); + sourceKind = String(sourceKind || '').trim(); + if (!rootUri && !sourceKind) return; + qsa('a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]').forEach( + function (link) { + if (!(link instanceof HTMLAnchorElement)) return; + try { + var url = new URL(link.getAttribute('href') || '/vault', window.location.origin); + if (url.pathname !== '/vault') return; + if (rootUri && !url.searchParams.get('rootUri')) { + url.searchParams.set('rootUri', rootUri); + } + if (sourceKind && !url.searchParams.get('sourceKind')) { + url.searchParams.set('sourceKind', sourceKind); + } + link.setAttribute('href', url.pathname + url.search + url.hash); + } catch (_e) {} + } + ); + } + + function boot() { + enrichVaultNavLinks(); + qsa('[data-testid="mnote-vault-workbench"]').forEach(bindWorkbench); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }); + } else { + boot(); + } + + // Capture-phase enrich only. Soft-open is owned by sidebar-tree-runtime + // (keeps PageTree/FileTree DOM). Full assign only when soft-open is unavailable. + if (!window.__mnoteVaultNavCapture) { + window.__mnoteVaultNavCapture = true; + document.addEventListener( + 'click', + function (ev) { + var t = ev.target; + if (!(t instanceof Element)) return; + if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return; + var link = t.closest( + 'a[data-mnote-nav="vault"], a[data-testid="mnote-nav-vault"], a[href="/vault"], a[href^="/vault?"]' + ); + if (!(link instanceof HTMLAnchorElement)) return; + try { + var url = new URL(link.getAttribute('href') || '/vault', window.location.origin); + if (url.pathname !== '/vault') return; + var rootUri = + (document.body && document.body.getAttribute('data-mnote-root-uri')) || + new URLSearchParams(window.location.search).get('rootUri') || + ''; + rootUri = String(rootUri || '').trim(); + if (rootUri && !url.searchParams.get('rootUri')) { + url.searchParams.set('rootUri', rootUri); + if (!url.searchParams.get('sourceKind')) { + url.searchParams.set('sourceKind', 'local_folder'); + } + link.setAttribute('href', url.pathname + url.search + url.hash); + } + // Prefer soft-open so sidebar tree expansion is preserved. + if (typeof window.__mnoteOpenVaultWorkbench === 'function') { + ev.preventDefault(); + void window.__mnoteOpenVaultWorkbench(url); + } + } catch (_e) {} + }, + true + ); + } + + window.mnote = window.mnote || {}; + window.mnote.vaultWorkbench = { boot: boot, enrichVaultNavLinks: enrichVaultNavLinks }; +})(); diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index e8f9b5fe..9ae000c2 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -18,6 +18,7 @@ use std::fs; use std::sync::Arc; #[cfg(not(test))] use std::time::Duration; +use tower_http::compression::CompressionLayer; use tower_http::trace::TraceLayer; use tracing::{error, warn}; @@ -332,6 +333,9 @@ fn open_turso_synced_control_plane_store() -> Arc { pub fn build_app(state: AppState) -> Router { build_router(state) + // Outer layers run first on request / last on response. Compress HTML/JSON + // for large local-folder SSR shells (PageTree/FileTree). + .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http().on_failure(())) .layer(axum::middleware::from_fn(log_failed_response)) .layer(axum::middleware::from_fn(inject_request_context)) diff --git a/rust/crates/mnote-web/src/hermes_tools/manifest.rs b/rust/crates/mnote-web/src/hermes_tools/manifest.rs index 1f1b3a26..c7805a82 100644 --- a/rust/crates/mnote-web/src/hermes_tools/manifest.rs +++ b/rust/crates/mnote-web/src/hermes_tools/manifest.rs @@ -355,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value { } json!({ "name": "mnote.knowledge_rag.status", - "description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 RAGFlow;WeKnora/LightRAG 仅作为 legacy fallback。", + "description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 LightRAG;WeKnora/RAGFlow 仅作为 env 显式切换的备用 / 调试路径。", "schemaVersion": TOOL_SCHEMA_VERSION, "capabilityScope": ["knowledge_rag.read", "evidence.read"], "status": "available", @@ -405,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value { } json!({ "name": "mnote.knowledge_rag.query", - "description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 RAGFlow,WeKnora/LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。", + "description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 LightRAG;WeKnora/RAGFlow 仅 env 备用。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。", "schemaVersion": TOOL_SCHEMA_VERSION, "capabilityScope": ["knowledge_rag.read", "evidence.read"], "status": "available", diff --git a/rust/crates/mnote-web/src/hermes_tools/skill.rs b/rust/crates/mnote-web/src/hermes_tools/skill.rs index ca61ecff..68260e97 100644 --- a/rust/crates/mnote-web/src/hermes_tools/skill.rs +++ b/rust/crates/mnote-web/src/hermes_tools/skill.rs @@ -38,7 +38,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[ MnoteCapabilityPack { id: "mnote-knowledge-rag", title: "知识库问答", - description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 RAGFlow。", + description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG。", category: "knowledge", agent_ids: &["hermes", "reasonix"], read_only: true, @@ -128,6 +128,26 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[ ], content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"), }, + MnoteCapabilityPack { + id: "mnote-vault", + title: "密码箱 / AI 密码本", + description: "密码箱与 AI 密码本使用约定:禁止通用文件工具读取 .mnote/vault;凭证经 vault API / 共享到 AI 密码本。", + category: "security", + agent_ids: &["hermes", "reasonix"], + read_only: true, + requires_context_refs: &["folder"], + tool_names: &[ + "mnote.context.snapshot", + "mnote.context.resolve_target", + // P1 vault tools (when registered); skill remains discoverable before tools land. + "mnote.vault.list", + "mnote.vault.get", + "mnote.vault.resolve", + "mnote.vault.login", + "mnote.vault.session", + ], + content: include_str!("../../../../../skills/mnote-vault/SKILL.md"), + }, MnoteCapabilityPack { id: "mnote-chat-only", title: "纯聊天", @@ -361,6 +381,32 @@ mod tests { .any(|name| name == "mnote.mindmap.create_from_outline")); } + #[test] + fn skill_registry_exposes_vault_skill_to_agents() { + let reasonix_skills = skill_summaries_for_agent(Some("reasonix")); + let hermes_skills = skill_summaries_for_agent(Some("hermes")); + let skill = reasonix_skills + .iter() + .find(|skill| skill["id"] == "mnote-vault") + .expect("reasonix should see vault skill"); + assert_eq!(skill["readOnly"], true); + assert_eq!(skill["category"], "security"); + assert!(skill["toolNames"] + .as_array() + .expect("tool names") + .iter() + .any(|name| name == "mnote.vault.resolve")); + assert!(hermes_skills + .iter() + .any(|skill| skill["id"] == "mnote-vault")); + assert!(find_skill("mnote-vault", Some("chat_only")).is_none()); + let body = find_skill("mnote-vault", Some("hermes")) + .expect("hermes can read vault skill") + .content; + assert!(body.contains(".mnote/vault")); + assert!(body.contains("共享到 AI") || body.contains("AI 密码本")); + } + #[test] fn skill_registry_retired_local_index_in_favor_of_lightrag() { let hermes_skills = skill_summaries_for_agent(Some("hermes")); diff --git a/rust/crates/mnote-web/src/routes/ai_settings.rs b/rust/crates/mnote-web/src/routes/ai_settings.rs index 7584b274..3ec1db0c 100644 --- a/rust/crates/mnote-web/src/routes/ai_settings.rs +++ b/rust/crates/mnote-web/src/routes/ai_settings.rs @@ -510,16 +510,17 @@ pub async fn user_access_scopes( /// `GET /api/ai-admin/access-scopes` /// /// Admin-only variant. Validates that the current actor has admin -/// privileges via `is_local_access_policy_admin_context` before -/// returning directory grants. +/// privileges from the active control-plane session before +/// returning **all** directory grants (not only the admin actor's own). +/// Source of truth matches `/api/admin/access-policy` → `list_directory_grants()`. pub async fn admin_access_scopes( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result, WebError> { - let actor_id = ensure_authenticated(&context)?; + let _actor_id = ensure_authenticated(&context)?; - if !local_folder_source::is_local_access_policy_admin_context(&context) { + if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) { return Err(WebError::new( StatusCode::FORBIDDEN, "ai_admin_access_forbidden", @@ -528,8 +529,13 @@ pub async fn admin_access_scopes( .with_context(&context)); } + // S3: admin SoT is the full grant table, not actor-scoped grants. + let grants = state + .control_plane() + .list_directory_grants() + .map_err(|e| WebError::internal(format!("读取目录授权失败: {e}")))?; let allowed_roots = - load_active_directory_grants(&state, &actor_id, query.workspace_id.as_deref())?; + filter_directory_grants_to_access_scopes(grants, query.workspace_id.as_deref()); Ok(Json(AccessScopesResponse { allowed_roots, source_of_truth: SOURCE_OF_TRUTH, @@ -551,7 +557,7 @@ pub async fn admin_receipts( Query(query): Query, ) -> Result, WebError> { let actor_id = ensure_authenticated(&context)?; - if !local_folder_source::is_local_access_policy_admin_context(&context) { + if !crate::routes::gateway::current_actor_is_local_admin(&state, &context) { return Err(WebError::new( StatusCode::FORBIDDEN, "ai_admin_access_forbidden", @@ -629,7 +635,7 @@ pub async fn admin_directory_access_requests( Extension(context): Extension, ) -> Result, WebError> { ensure_authenticated(&context)?; - ensure_admin(&context)?; + ensure_admin(&state, &context)?; let requests = list_directory_access_requests(&state, None)?; Ok(Json(json!({ "ok": true, @@ -645,7 +651,7 @@ pub async fn approve_directory_access_request( Json(_body): Json, ) -> Result, WebError> { let actor_id = ensure_authenticated(&context)?; - ensure_admin(&context)?; + ensure_admin(&state, &context)?; let request = find_pending_directory_access_request(&state, &request_id)?; let user_id = request .get("userId") @@ -720,7 +726,7 @@ pub async fn reject_directory_access_request( Json(body): Json, ) -> Result, WebError> { let actor_id = ensure_authenticated(&context)?; - ensure_admin(&context)?; + ensure_admin(&state, &context)?; let _ = find_pending_directory_access_request(&state, &request_id)?; let metadata = json!({ "requestId": request_id, @@ -901,7 +907,7 @@ pub async fn admin_get_settings( Extension(context): Extension, ) -> Result, WebError> { let actor_id = ensure_authenticated(&context)?; - ensure_admin(&context)?; + ensure_admin(&state, &context)?; let global_owner = global_policy_owner_id(&actor_id); let policy = state @@ -939,7 +945,7 @@ pub async fn admin_put_settings( Json(body): Json, ) -> Result, WebError> { let actor_id = ensure_authenticated(&context)?; - ensure_admin(&context)?; + ensure_admin(&state, &context)?; let global_owner = global_policy_owner_id(&actor_id); // ── Validate provider secretRefs ────────────────────────────────── @@ -1036,7 +1042,7 @@ pub async fn admin_list_users( Extension(context): Extension, ) -> Result, WebError> { ensure_authenticated(&context)?; - ensure_admin(&context)?; + ensure_admin(&state, &context)?; let users = state .control_plane() .list_users(500) @@ -1049,7 +1055,7 @@ pub async fn admin_list_users( "email": user.email, "username": user.username, "displayName": user.display_name, - "role": if is_configured_admin_user(&user.id) { "admin" } else { user.role.as_str() }, + "role": if is_admin_user_for_display(&user.id, &user.role) { "admin" } else { user.role.as_str() }, "status": user.status, "createdAt": user.created_at, "updatedAt": user.updated_at, @@ -1069,7 +1075,7 @@ pub async fn admin_get_user_settings( Path(user_id): Path, ) -> Result, WebError> { let actor_id = ensure_authenticated(&context)?; - ensure_admin(&context)?; + ensure_admin(&state, &context)?; ensure_known_user(&state, &user_id)?; let global_owner = global_policy_owner_id(&actor_id); let global_policy = load_policy_value(&state, &global_owner, None); @@ -1092,7 +1098,7 @@ pub async fn admin_put_user_settings( Json(body): Json, ) -> Result, WebError> { let actor_id = ensure_authenticated(&context)?; - ensure_admin(&context)?; + ensure_admin(&state, &context)?; ensure_known_user(&state, &user_id)?; let global_owner = global_policy_owner_id(&actor_id); if user_id == global_owner { @@ -1567,12 +1573,30 @@ fn default_mcp_server_registry() -> HashMap { servers } +fn policy_removed_default_ids(model_policy: &Value, key: &str) -> std::collections::HashSet { + model_policy + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(|value| value.trim().to_string())) + .filter(|value| !value.is_empty()) + .collect() + }) + .unwrap_or_default() +} + fn effective_skill_registry(model_policy: &Value) -> HashMap { let mut skills = default_skill_registry(); let configured = policy_map::(model_policy, "skills"); for (id, config) in configured { skills.insert(id, config); } + // S2: omit built-in defaults the admin explicitly deleted (tombstones). + for id in policy_removed_default_ids(model_policy, "removedDefaultSkills") { + skills.remove(&id); + } skills } @@ -1582,6 +1606,9 @@ fn effective_mcp_registry(model_policy: &Value) -> HashMap HashMap bool { .unwrap_or(false) } +fn is_admin_user_for_display(user_id: &str, stored_role: &str) -> bool { + local_folder_source::is_local_access_policy_admin_actor(user_id, stored_role) + || is_configured_admin_user(user_id) +} + fn load_policy_value(state: &AppState, actor_id: &str, workspace_id: Option<&str>) -> Value { load_model_policy_and_quota(state, actor_id, workspace_id).0 } @@ -2238,8 +2273,8 @@ fn filter_directory_grants_to_access_scopes( /// Ensures admin auth. Reuses the existing admin check from /// `local_folder_source::is_local_access_policy_admin_context`. -fn ensure_admin(context: &RequestContext) -> Result<(), WebError> { - if !local_folder_source::is_local_access_policy_admin_context(context) { +fn ensure_admin(state: &AppState, context: &RequestContext) -> Result<(), WebError> { + if !crate::routes::gateway::current_actor_is_local_admin(state, context) { return Err(WebError::new( StatusCode::FORBIDDEN, "ai_admin_access_forbidden", @@ -2634,13 +2669,22 @@ fn merge_policy_with_existing( // The admin settings UI edits this registry as the desired full // state. Re-merging deleted rows back from the persisted policy // makes "删除" a no-op, so presence of this section means replace. + // + // S2: also write tombstones for built-in defaults omitted from the + // payload, so effective_*_registry does not rehydrate ghosts. if let Some(ref skills) = body.skills { let mut merged = serde_json::Map::new(); for (k, v) in skills { let val = serde_json::to_value(v).unwrap_or(json!({})); merged.insert(k.clone(), val); } + let removed_defaults: Vec = default_skill_registry() + .keys() + .filter(|id| !merged.contains_key(id.as_str())) + .cloned() + .collect(); model_policy["skills"] = Value::Object(merged); + model_policy["removedDefaultSkills"] = json!(removed_defaults); } // ── Replace MCP servers map ───────────────────────────────────── @@ -2653,7 +2697,13 @@ fn merge_policy_with_existing( let val = serde_json::to_value(v).unwrap_or(json!({})); merged.insert(k.clone(), val); } + let removed_defaults: Vec = default_mcp_server_registry() + .keys() + .filter(|id| !merged.contains_key(id.as_str())) + .cloned() + .collect(); model_policy["mcpServers"] = Value::Object(merged); + model_policy["removedDefaultMcpServers"] = json!(removed_defaults); } if let Some(ref extensions) = body.pi_extensions { @@ -2662,7 +2712,13 @@ fn merge_policy_with_existing( let val = serde_json::to_value(v).unwrap_or(json!({})); merged.insert(k.clone(), val); } + let removed_defaults: Vec = default_pi_extension_registry() + .keys() + .filter(|id| !merged.contains_key(id.as_str())) + .cloned() + .collect(); model_policy["piExtensions"] = Value::Object(merged); + model_policy["removedDefaultPiExtensions"] = json!(removed_defaults); } let model_policy_json = serde_json::to_string(&model_policy).unwrap_or_else(|_| "{}".into()); @@ -3253,6 +3309,79 @@ mod tests { assert_eq!(parsed["skills"]["keep-skill"]["enabled"], true); assert!(parsed["mcpServers"].as_object().unwrap().is_empty()); assert!(parsed["piExtensions"].as_object().unwrap().is_empty()); + // S2: omitting a built-in default from the replace payload tombs it. + let removed_skills = parsed["removedDefaultSkills"] + .as_array() + .expect("removedDefaultSkills"); + assert!( + removed_skills.iter().any(|v| v.as_str() == Some("vpn")), + "vpn default skill should be tombstoned when omitted: {parsed}" + ); + let removed_mcp = parsed["removedDefaultMcpServers"] + .as_array() + .expect("removedDefaultMcpServers"); + assert!( + removed_mcp + .iter() + .any(|v| v.as_str() == Some("context7")), + "context7 default MCP should be tombstoned when omitted: {parsed}" + ); + } + + /// S2: deleted default skills/MCP must not reappear via effective registries. + #[test] + fn effective_registries_honor_removed_default_tombstones() { + let policy = json!({ + "skills": { + "custom-only": { + "name": "Custom Only", + "enabled": true, + "description": "", + "source": "/tmp/custom/SKILL.md", + "riskLevel": "low", + "requiredScopes": [] + } + }, + "removedDefaultSkills": ["vpn", "chrome-bridge"], + "mcpServers": { + "custom-mcp": { + "name": "Custom MCP", + "enabled": true, + "url": "", + "transport": "stdio", + "command": "custom-mcp", + "networkPolicy": "deny-all", + "secretRefs": [], + "facadeOnly": true, + "sandbox": true, + "description": "", + "riskLevel": "medium", + "requiredScopes": [] + } + }, + "removedDefaultMcpServers": ["context7", "codegraph"] + }); + let skills = effective_skill_registry(&policy); + assert!(skills.contains_key("custom-only")); + assert!( + !skills.contains_key("vpn"), + "tombstoned default skill must not rehydrate" + ); + assert!(!skills.contains_key("chrome-bridge")); + // Untombstoned defaults still present. + assert!(skills.contains_key("context7") || skills.contains_key("searxng")); + + let mcps = effective_mcp_registry(&policy); + assert!(mcps.contains_key("custom-mcp")); + assert!(!mcps.contains_key("context7")); + assert!(!mcps.contains_key("codegraph")); + + // Admin projection must match effective (no ghost defaults). + let admin = project_admin_settings(&policy, &json!({}), None); + assert!(!admin.skills.contains_key("vpn")); + assert!(admin.skills.contains_key("custom-only")); + assert!(!admin.mcp_servers.contains_key("context7")); + assert!(admin.mcp_servers.contains_key("custom-mcp")); } // ─── Admin projection ──────────────────────────────────────────────── @@ -3479,4 +3608,27 @@ mod tests { }; assert!(validate_user_policy_body(&body, &global).is_ok()); } + + #[test] + fn admin_user_display_role_includes_access_policy_admins() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + let policy_root = std::env::temp_dir().join(format!( + "mnote-ai-settings-display-admin-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&policy_root); + std::fs::create_dir_all(&policy_root).expect("create policy root"); + let policy_file = policy_root.join("access-policy.json"); + std::fs::write(&policy_file, r#"{"admins":["liaibo"],"grants":[]}"#) + .expect("write access policy"); + std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file); + + assert!(is_admin_user_for_display("liaibo", "user")); + assert!(!is_admin_user_for_display("shujuan", "user")); + + std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE"); + let _ = std::fs::remove_dir_all(&policy_root); + } } diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index f1ad4b9b..50521ba9 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -4,9 +4,10 @@ use crate::error::WebError; use crate::provider_identity_sync::sync_provider_identities; use crate::routes::local_folder_source::{ create_default_local_workspace_for_actor, ensure_local_workspace_read_access_with_state, - is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot, - load_local_folder_file_tree_snapshot, load_local_folder_page_tree_scope_snapshot, - load_local_folder_page_tree_snapshot, load_local_trash_entries, + is_local_access_policy_admin_actor, is_local_access_policy_admin_context, + load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot, + load_local_folder_page_tree_scope_snapshot_with_reveal, + load_local_folder_page_tree_snapshot_with_reveal, load_local_trash_entries, }; use crate::routes::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ @@ -15,8 +16,8 @@ use crate::routes::web_shell::{ load_file_tree_html, load_sidebar_tree_html, load_workspace_shell_projection, render_document_title_controller_script, render_editor_island_adapter_script, render_editor_runtime_preload_links, render_local_file_tree_html, - render_local_file_tree_html_scoped, render_local_sidebar_tree_html, - render_local_sidebar_tree_html_from_snapshot, + render_local_file_tree_html_scoped, render_local_file_tree_pending_shell_html, + render_local_sidebar_tree_html, render_local_sidebar_tree_html_from_snapshot, }; use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name; use crate::workspace_shell::{ @@ -188,7 +189,7 @@ pub async fn admin_access_policy_entry( stamp_gateway_headers(response.headers_mut(), false); return Ok(response); } - if !is_local_access_policy_admin_context(&context) { + if !current_actor_is_local_admin(&state, &context) { return Err(WebError::new( StatusCode::FORBIDDEN, "local_access_policy_admin_required", @@ -240,7 +241,7 @@ pub async fn admin_ai_entry( stamp_gateway_headers(response.headers_mut(), false); return Ok(response); } - if !is_local_access_policy_admin_context(&context) { + if !current_actor_is_local_admin(&state, &context) { return Err(WebError::new( StatusCode::FORBIDDEN, "ai_admin_required", @@ -267,7 +268,7 @@ pub async fn settings_entry( ai_management_response( &state, &context, - is_local_access_policy_admin_context(&context), + current_actor_is_local_admin(&state, &context), ) } @@ -410,10 +411,15 @@ pub async fn root_entry( WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") })?; ensure_local_workspace_read_access_with_state(&state, &context, root_uri)?; + // Reveal only the active page path; keep SSR PageTree shallow (Sidex-aligned). let page_tree_snapshot = if let Some(scope) = file_tree_scope { - load_local_folder_page_tree_scope_snapshot(root_uri, scope)? + load_local_folder_page_tree_scope_snapshot_with_reveal( + root_uri, + scope, + requested_page_id.as_deref(), + )? } else { - load_local_folder_page_tree_snapshot(root_uri)? + load_local_folder_page_tree_snapshot_with_reveal(root_uri, requested_page_id.as_deref())? }; let workspace_id = page_tree_snapshot .dataset @@ -452,12 +458,19 @@ pub async fn root_entry( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); - let file_tree_html = render_local_file_tree_html_scoped( - root_uri, - selected_active_page_id.as_deref(), - restore_focus_row_id, - file_tree_scope, - )?; + // Shell-first: when landing on PageTree (default), do not block home SSR on FileTree scan. + // treeView=filetree (or restore focus into file rows) still needs synchronous FileTree HTML. + let needs_sync_file_tree = requests_filetree_first || restore_focus_row_id.is_some(); + let file_tree_html = if needs_sync_file_tree { + render_local_file_tree_html_scoped( + root_uri, + selected_active_page_id.as_deref(), + restore_focus_row_id, + file_tree_scope, + )? + } else { + render_local_file_tree_pending_shell_html() + }; ( workspace_id, workspace_projection, @@ -480,7 +493,8 @@ pub async fn root_entry( WebError::internal("默认本地工作区初始化未返回 rootUri").with_context(&context) })? .to_string(); - let snapshot = load_local_folder_page_tree_snapshot(&root_uri)?; + let snapshot = + load_local_folder_page_tree_snapshot_with_reveal(&root_uri, requested_page_id.as_deref())?; let workspace_id = snapshot .dataset .get("workspace") @@ -511,8 +525,12 @@ pub async fn root_entry( &snapshot, selected_active_page_id.as_deref(), ); - let file_tree_html = - render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)?; + // Local-first landing also prefers shell-first FileTree (hydrate after paint). + let file_tree_html = if requests_filetree_first { + render_local_file_tree_html(&root_uri, selected_active_page_id.as_deref(), None)? + } else { + render_local_file_tree_pending_shell_html() + }; ( workspace_id, workspace_projection, @@ -593,7 +611,11 @@ 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 breadcrumb_html = crate::workspace_shell::render_page_breadcrumb_html( + &workspace_projection, + Some(active_page_id.as_str()), + ); + let show_admin_access_policy = current_actor_is_local_admin(&state, &context); let navigation_notice_html = render_navigation_guard_notice(&query); let navigation_html = if active_page_id.trim().is_empty() { if active_source_kind.as_deref() == Some("local_folder") { @@ -618,6 +640,10 @@ pub async fn root_entry( } else { None }; + let vault_nav_href = vault_nav_href_for_context( + active_source_kind.as_deref(), + active_root_uri.as_deref(), + ); let render_workspace_entry = || { crate::ssr::render_view(leptos::view! { }) }; @@ -708,6 +736,7 @@ document.body.appendChild(s); page_subtree_json={page_subtree_json} show_admin_access_policy={show_admin_access_policy} enable_tree_live={true} + vault_nav_href={vault_nav_href.clone()} /> }); let body_extra = format!( @@ -1113,6 +1142,304 @@ fn query_escape(value: &str) -> String { .collect() } +/// SSR vault nav href with sourceKind+rootUri so first click keeps local_folder context. +pub(crate) fn vault_nav_href_for_context(source_kind: Option<&str>, root_uri: Option<&str>) -> String { + let root_uri = root_uri.map(str::trim).filter(|value| !value.is_empty()); + let source_kind = source_kind + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| root_uri.map(|_| "local_folder")); + match (source_kind, root_uri) { + (Some(source_kind), Some(root_uri)) => format!( + "/vault?sourceKind={}&rootUri={}", + query_escape(source_kind), + query_escape(root_uri) + ), + (Some(source_kind), None) => { + format!("/vault?sourceKind={}", query_escape(source_kind)) + } + (None, Some(root_uri)) => format!( + "/vault?sourceKind=local_folder&rootUri={}", + query_escape(root_uri) + ), + (None, None) => "/vault".to_string(), + } +} + +/// GET /files → permanent product surface is /vault (left-rail password vault). +pub async fn files_redirect_to_vault( + Query(query): Query, +) -> Result { + let mut location = String::from("/vault"); + let mut parts: Vec = Vec::new(); + if let Some(root_uri) = query + .root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + parts.push(format!("rootUri={}", query_escape(root_uri))); + } + if let Some(source_kind) = query + .source_kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + parts.push(format!("sourceKind={}", query_escape(source_kind))); + } else if !parts.is_empty() { + parts.push("sourceKind=local_folder".to_string()); + } + if let Some(workspace_id) = query + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + parts.push(format!("workspaceId={}", query_escape(workspace_id))); + } + if !parts.is_empty() { + location.push('?'); + location.push_str(&parts.join("&")); + } + let mut response = Response::builder() + .status(StatusCode::SEE_OTHER) + .header(header::LOCATION, location) + .body(Body::empty()) + .map_err(|error| WebError::internal(format!("/files 跳转响应构造失败: {error}")))?; + stamp_gateway_headers(response.headers_mut(), false); + Ok(response) +} + +/// GET /vault — dedicated password vault workbench (local_folder only for P0). +pub async fn vault_entry( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result { + if !crate::routes::vault::vault_feature_enabled() { + return Err(crate::routes::vault::vault_disabled_error().with_context(&context)); + } + if !has_real_auth_context(&state, &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); + } + + let source_kind = query + .source_kind + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let root_uri = query + .root_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + // Product path: local_folder workspace with rootUri. + if source_kind == Some("local_folder") || root_uri.is_some() { + let root_uri = root_uri.ok_or_else(|| { + WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") + })?; + ensure_local_workspace_read_access_with_state(&state, &context, root_uri) + .map_err(|error| error.with_context(&context))?; + let workspace_root = + ensure_local_workspace_read_access_with_state(&state, &context, root_uri) + .map_err(|error| error.with_context(&context))?; + // Best-effort ensure vault dirs so first paint has a writable layout. + let _ = crate::routes::vault_store::ensure_vault_directories(&workspace_root); + let workspace_id = + crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?; + let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default(); + let file_tree_html = render_local_file_tree_pending_shell_html(); + let mut workspace_dataset = json!({ + "active_workspace_id": workspace_id, + "workspaces": [{ "id": workspace_id, "name": "我的空间" }], + "documents": [], + }); + attach_sidebar_shortcuts_to_dataset( + &state, + &context, + &workspace_id, + &mut workspace_dataset, + ); + let workspace_projection = + build_workspace_shell_projection(&workspace_dataset, &workspace_id, None, "我的空间"); + let workspace_sidebar_html = render_workspace_shell_sidebar_html( + &workspace_projection, + Some(sidebar_tree_html.as_str()), + Some(file_tree_html.as_str()), + None, + None, + ); + let bootstrap = crate::routes::vault::bootstrap_list_json( + &workspace_root, + crate::routes::vault_store::VaultItemStatus::Active, + ) + .unwrap_or_else(|_| { + json!({ + "schema": "mnote.vault.list.v1", + "status": "active", + "revision": 0, + "updatedAt": "", + "items": [], + }) + }); + let vault_workbench_html = + render_vault_workbench_html(&workspace_id, root_uri, &bootstrap); + let vault_nav_href = vault_nav_href_for_context(Some("local_folder"), Some(root_uri)); + let content = crate::ssr::render_view(leptos::view! { + +
    +
    + }); + + let mut response = Html(format!( + r#" + + + + 密码箱 + + + + {} + +"#, + crate::ssr::MNOTE_CSS, + escape_html(root_uri), + content, + )) + .into_response(); + stamp_gateway_headers(response.headers_mut(), false); + context.apply_response_headers(response.headers_mut()); + return Ok(response); + } + + // Bare /vault without rootUri: friendly local-first shell (no cloud workspace bootstrap). + // Avoid resolve_root_workspace_id → retired Convex ensureDefaultWorkspace → 503. + let default_workspace_name = default_workspace_name_for_context(&state, &context); + let workspace_id = normalize_optional_id(query.workspace_id.as_deref()) + .or_else(|| normalize_optional_id(context.workspace.workspace_id.as_deref())) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "local-folder".to_string()); + let mut workspace_dataset = json!({ + "active_workspace_id": workspace_id, + "workspaces": [{ "id": workspace_id, "name": default_workspace_name }], + "documents": [], + }); + attach_sidebar_shortcuts_to_dataset( + &state, + &context, + &workspace_id, + &mut workspace_dataset, + ); + let workspace_projection = build_workspace_shell_projection( + &workspace_dataset, + &workspace_id, + None, + &default_workspace_name, + ); + let workspace_sidebar_html = render_workspace_shell_sidebar_html( + &workspace_projection, + Some(""), + Some(""), + None, + None, + ); + let empty_bootstrap = json!({ + "schema": "mnote.vault.list.v1", + "status": "active", + "revision": 0, + "updatedAt": "", + "items": [], + "needsLocalFolder": true, + }); + let vault_workbench_html = + render_vault_workbench_html(&workspace_id, "", &empty_bootstrap); + let workspace_name = workspace_projection.workspace_name.clone(); + let content = crate::ssr::render_view(leptos::view! { + +
    +
    + }); + + let mut response = Html(format!( + r#" + + + + 密码箱 + + + + {} + +"#, + crate::ssr::MNOTE_CSS, + content, + )) + .into_response(); + stamp_gateway_headers(response.headers_mut(), false); + context.apply_response_headers(response.headers_mut()); + Ok(response) +} + +fn render_vault_workbench_html(workspace_id: &str, root_uri: &str, bootstrap: &Value) -> String { + let bootstrap_raw = bootstrap.to_string(); + let bootstrap_json = escape_script_json(&bootstrap_raw); + let runtime_src = crate::routes::web_shell::mnote_browser_runtime_src("vault-workbench-runtime.js"); + format!( + r#"
    +
    +
    +

    密码箱

    +

    +
    +
    + + + +
    + + +
    +
    +
    +
    + +
    +
    + + +
    "#, + workspace_id = escape_html(workspace_id), + root_uri_esc = escape_html(root_uri), + bootstrap_json = bootstrap_json, + runtime_src = runtime_src, + ) +} + pub async fn trash_entry( State(state): State, Extension(context): Extension, @@ -1147,7 +1474,7 @@ pub async fn trash_entry( let workspace_id = crate::routes::local_folder_source::local_workspace_id_from_root_uri(root_uri)?; let sidebar_tree_html = render_local_sidebar_tree_html(root_uri, None).unwrap_or_default(); - let file_tree_html = render_local_file_tree_html(root_uri, None, None).unwrap_or_default(); + let file_tree_html = render_local_file_tree_pending_shell_html(); let mut workspace_dataset = json!({ "active_workspace_id": workspace_id, "workspaces": [{ "id": workspace_id, "name": "我的空间" }], @@ -2155,6 +2482,16 @@ pub(crate) fn current_actor_type(state: &AppState, context: &RequestContext) -> context.auth.actor_type.trim().to_string() } +pub(crate) fn current_actor_is_local_admin(state: &AppState, context: &RequestContext) -> bool { + if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) { + let token_hash = session_token_hash(&raw_token); + if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) { + return is_local_access_policy_admin_actor(&resolved.user.id, &resolved.user.role); + } + } + is_local_access_policy_admin_context(context) +} + fn normalize_optional_id(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } @@ -3232,6 +3569,104 @@ mod tests { assert!(!html.contains("开发用户 的空间")); } + #[tokio::test] + async fn control_plane_user_session_overrides_stale_admin_actor_cookies_for_settings() { + use control_plane::{session_token_hash, CreateSessionInput}; + + let app_state = AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: false, + enable_debug_shell_routes: false, + enable_editor_actor: true, + enable_page_ai_pi_lab: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + }); + app_state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("normal-user-session".into()), + email: Some("normal@example.com".into()), + username: "normal-user-session".into(), + display_name: "普通用户".into(), + role: Some("user".into()), + password_hash: None, + }) + .expect("upsert control-plane user"); + app_state + .control_plane() + .create_session(CreateSessionInput { + id: None, + user_id: "normal-user-session".into(), + token_hash: session_token_hash("normal-session-token"), + user_agent: None, + ip_hash: None, + expires_at: None, + }) + .expect("create control-plane session"); + let app = build_app(app_state); + let stale_admin_cookie = + "mnote_session=normal-session-token; mnote_actor_id=stale-admin; mnote_actor_type=admin"; + + let settings_response = app + .clone() + .oneshot( + Request::builder() + .uri("/settings") + .header("cookie", stale_admin_cookie) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("settings response"); + + assert_eq!(settings_response.status(), StatusCode::OK); + let body = to_bytes(settings_response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("data-ai-admin-role=\"user\"")); + assert!(!html.contains("data-ai-admin-role=\"admin\"")); + assert!(!html.contains("href=\"#ai-admin-users\"")); + + let admin_page_response = app + .clone() + .oneshot( + Request::builder() + .uri("/admin/ai") + .header("cookie", stale_admin_cookie) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("admin page response"); + assert_eq!(admin_page_response.status(), StatusCode::FORBIDDEN); + + let admin_api_response = app + .oneshot( + Request::builder() + .uri("/api/ai-admin/users") + .header("cookie", stale_admin_cookie) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("admin api response"); + assert_eq!(admin_api_response.status(), StatusCode::FORBIDDEN); + } + #[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) @@ -3780,10 +4215,105 @@ mod tests { assert!(!html.contains("当前还没有可显示的本地工作区")); assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#)); assert!(html.contains(r#""transport":"tree-live-ws""#)); + // Vault left-rail should carry local_folder context in SSR href (no full refresh to bare /vault). + // Leptos HTML-escapes `&` as `&` in attribute values — match fragments, not full attribute. + let vault_href_ok = html.contains(r#"data-testid="mnote-nav-vault""#) + && html.contains("/vault?sourceKind=local_folder") + && html.contains("rootUri="); + assert!( + vault_href_ok, + "vault nav SSR href must include sourceKind+rootUri: {}", + html.lines() + .find(|line| line.contains("mnote-nav-vault")) + .unwrap_or("(no vault nav line)") + ); let _ = std::fs::remove_dir_all(&base); } + #[tokio::test] + async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() { + let _guard = crate::test_support::hermes_env_lock() + .lock() + .expect("env lock"); + let response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri("/vault") + .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, + "bare /vault must not 503 via retired cloud workspace bootstrap" + ); + assert_ne!( + response + .headers() + .get("x-error-code") + .and_then(|value| value.to_str().ok()), + Some("convex_retired") + ); + 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-mnote-page="vault""#) + || html.contains(r#"data-testid="mnote-vault-workbench""#) + || html.contains("密码箱"), + "bare vault should render password vault shell: {}", + &html[..html.len().min(500)] + ); + assert!( + html.contains(r#"data-mnote-vault-needs-folder="1""#) + || html.contains("needsLocalFolder"), + "bare vault should signal needs local folder" + ); + } + + #[tokio::test] + async fn root_entry_local_folder_accepts_gzip_encoding() { + let root = temp_root("mnote-root-local-folder-gzip"); + std::fs::write(root.join("README.md"), "# Gzip\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + crate::routes::local_folder_source::initialize_local_workspace_for_actor( + "user_real", + &root_uri, + ) + .expect("init"); + let response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri(format!("/?sourceKind=local_folder&rootUri={root_uri}")) + .header("x-mnote-actor-id", "user_real") + .header("x-mnote-actor-type", "user") + .header(header::ACCEPT_ENCODING, "gzip, br") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + let _ = std::fs::remove_dir_all(&root); + assert_eq!(response.status(), StatusCode::OK); + let encoding = response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + // CompressionLayer may skip tiny bodies; accept either gzip/br or uncompressed OK. + assert!( + encoding.is_empty() || encoding.contains("gzip") || encoding.contains("br"), + "unexpected content-encoding: {encoding}" + ); + } + #[tokio::test] async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() { let response = app_with_config("http://127.0.0.1:3100".into(), false) diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index bc58d6eb..f4ae8ce2 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -4,7 +4,9 @@ use crate::error::WebError; use crate::routes::local_folder_source::{ ensure_local_workspace_read_access_with_state, load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal, - load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot, + load_local_folder_page_tree_scope_snapshot, + load_local_folder_page_tree_scope_snapshot_with_reveal, + load_local_folder_page_tree_snapshot, load_local_folder_page_tree_snapshot_with_reveal, }; use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::snapshot_support::{ @@ -121,7 +123,21 @@ async fn project_projection( load_local_folder_file_tree_snapshot(&root_uri) } } else if let Some(parent_relative_path) = parent_relative_path.as_deref() { - load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path) + // Children fetch stays shallow; only reveal when root_node_id is present. + if root_node_id.is_some() { + load_local_folder_page_tree_scope_snapshot_with_reveal( + &root_uri, + parent_relative_path, + root_node_id.as_deref(), + ) + } else { + load_local_folder_page_tree_scope_snapshot(&root_uri, parent_relative_path) + } + } else if root_node_id.is_some() { + load_local_folder_page_tree_snapshot_with_reveal( + &root_uri, + root_node_id.as_deref(), + ) } else { load_local_folder_page_tree_snapshot(&root_uri) } diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index f2548211..a803cd28 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -9,7 +9,7 @@ use crate::routes::local_markdown_parser::{ file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter, }; use crate::routes::snapshot_support::ProjectionSnapshot; -use crate::routes::{knowledge_rag, local_ocr, local_search_index}; +use crate::routes::{knowledge_rag, local_search_index}; use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::Json; @@ -44,6 +44,9 @@ static LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS: std::sync::atomic::AtomicU64 = #[cfg(test)] static LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS_BY_ROOT: OnceLock>> = OnceLock::new(); +#[cfg(test)] +static LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY: OnceLock>> = + OnceLock::new(); #[derive(Debug, Clone)] struct LocalPageTreeSnapshotCacheEntry { @@ -55,6 +58,43 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock< Mutex>, > = OnceLock::new(); +/// Process-local cache for heavy `.mnote` metadata (especially uploaded-assets.json). +/// Keyed by canonical root; invalidated by file mtime/size fingerprints or explicit writes. +#[derive(Debug, Clone)] +struct LocalFolderMetadataCacheEntry { + fingerprints: [u64; 4], + metadata: LocalFolderMetadata, +} + +static LOCAL_FOLDER_METADATA_CACHE: OnceLock>> = + OnceLock::new(); + +/// Short-lived cache for full recursive watch revisions (live poll / events). +/// Avoids re-walking multi-thousand-entry trees on every client poll tick. +#[derive(Debug, Clone)] +struct LocalFolderWatchRevisionCacheEntry { + revision: LocalFolderWatchRevision, + cached_at_ms: u128, +} + +static LOCAL_FOLDER_WATCH_REVISION_CACHE: OnceLock< + Mutex>, +> = OnceLock::new(); + +/// Default TTL for full recursive watch revisions (live poll / events). +/// Tests can override via [`set_local_folder_watch_revision_cache_ttl_ms_for_test`]. +static LOCAL_FOLDER_WATCH_REVISION_CACHE_TTL_MS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(2_000); + +#[cfg(test)] +pub(crate) fn set_local_folder_watch_revision_cache_ttl_ms_for_test(ttl_ms: u64) { + LOCAL_FOLDER_WATCH_REVISION_CACHE_TTL_MS.store(ttl_ms, std::sync::atomic::Ordering::SeqCst); +} + +fn local_folder_watch_revision_cache_ttl_ms() -> u128 { + u128::from(LOCAL_FOLDER_WATCH_REVISION_CACHE_TTL_MS.load(std::sync::atomic::Ordering::SeqCst)) +} + #[cfg(not(test))] static LOCAL_SEARCH_INDEX_CONTROL_PLANE: OnceLock< std::sync::Arc, @@ -65,6 +105,54 @@ fn local_page_tree_snapshot_cache( LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new())) } +fn local_folder_metadata_cache() -> &'static Mutex> { + LOCAL_FOLDER_METADATA_CACHE.get_or_init(|| Mutex::new(BTreeMap::new())) +} + +fn local_folder_watch_revision_cache( +) -> &'static Mutex> { + LOCAL_FOLDER_WATCH_REVISION_CACHE.get_or_init(|| Mutex::new(BTreeMap::new())) +} + +fn metadata_file_fingerprint(path: &Path) -> u64 { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + match fs::metadata(path) { + Ok(meta) => { + meta.len().hash(&mut hasher); + if let Ok(modified) = meta.modified() { + if let Ok(delta) = modified.duration_since(UNIX_EPOCH) { + delta.as_millis().hash(&mut hasher); + } + } + } + Err(_) => { + 0u8.hash(&mut hasher); + } + } + hasher.finish() +} + +fn local_folder_metadata_fingerprints(root: &Path) -> [u64; 4] { + let mnote = root.join(".mnote"); + [ + metadata_file_fingerprint(&mnote.join("page-options.json")), + metadata_file_fingerprint(&mnote.join("trash-index.json")), + metadata_file_fingerprint(&mnote.join("uploaded-assets.json")), + metadata_file_fingerprint(&mnote.join("file-order.json")), + ] +} + +fn invalidate_local_folder_metadata_cache(root: &Path) { + let key = root.to_string_lossy().to_string(); + if let Ok(mut cache) = local_folder_metadata_cache().lock() { + cache.remove(&key); + } + if let Ok(mut cache) = local_folder_watch_revision_cache().lock() { + cache.remove(&key); + } +} + #[cfg(test)] pub(crate) fn reset_local_page_tree_snapshot_test_loads() { LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.store(0, std::sync::atomic::Ordering::SeqCst); @@ -74,6 +162,11 @@ pub(crate) fn reset_local_page_tree_snapshot_test_loads() { loads.clear(); } } + if let Some(loads) = LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY.get() { + if let Ok(mut loads) = loads.lock() { + loads.clear(); + } + } if let Some(cache) = LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get() { if let Ok(mut cache) = cache.lock() { cache.clear(); @@ -92,8 +185,17 @@ pub(crate) fn local_page_tree_snapshot_test_loads_for_root(root_uri: &str) -> u6 } #[cfg(test)] -fn local_page_tree_snapshot_scan_test_loads() -> u64 { - LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.load(std::sync::atomic::Ordering::SeqCst) +fn local_page_tree_snapshot_scan_test_loads_for_key( + root_uri: &str, + parent_relative_path: &str, +) -> u64 { + let key = format!("{root_uri}\n{parent_relative_path}"); + LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .ok() + .and_then(|loads| loads.get(&key).copied()) + .unwrap_or(0) } #[derive(Debug, Clone)] @@ -2648,6 +2750,18 @@ fn default_local_workspace_base_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(DEFAULT_LOCAL_WORKSPACE_BASE_DIR)) } +/// Managed default workspace root for an actor: +/// `{base}/users/{encodedActor}/workspaces/my-space`. +/// Used by vault → AI password-book share (no open-folder ACL required). +pub(crate) fn managed_default_workspace_root_for_actor(actor_id: &str) -> PathBuf { + let actor_segment = encode_local_id_segment(actor_id.trim()); + default_local_workspace_base_dir() + .join("users") + .join(actor_segment) + .join("workspaces") + .join(DEFAULT_LOCAL_WORKSPACE_SLUG) +} + fn ensure_default_workspace_manifest( actor_id: &str, root: &Path, @@ -2684,7 +2798,15 @@ fn ensure_default_workspace_manifest( } fn ensure_default_workspace_directories(root: &Path) -> Result<(), WebError> { - for relative in [".mnote/trash"] { + for relative in [ + ".mnote/trash", + ".mnote/vault", + ".mnote/vault/entries", + ".mnote/vault/attachments", + ".mnote/vault/trash", + ".mnote/vault/trash/entries", + ".mnote/vault/trash/attachments", + ] { let path = root.join(relative); if !path.starts_with(root) { return Err(WebError::bad_request_code( @@ -2909,19 +3031,49 @@ fn load_local_folder_file_tree_scope_snapshot( pub fn load_local_folder_page_tree_snapshot( root_uri: &str, ) -> Result { - load_local_folder_page_tree_snapshot_for_scope(root_uri, None) + load_local_folder_page_tree_snapshot_for_scope(root_uri, None, None) +} + +pub fn load_local_folder_page_tree_snapshot_with_reveal( + root_uri: &str, + reveal_document_id: Option<&str>, +) -> Result { + let reveal_relative_path = reveal_document_id + .and_then(local_markdown_relative_path_from_document_id) + .map(|path| path.replace('\\', "/")); + load_local_folder_page_tree_snapshot_for_scope( + root_uri, + None, + reveal_relative_path.as_deref(), + ) } pub fn load_local_folder_page_tree_scope_snapshot( root_uri: &str, parent_relative_path: &str, ) -> Result { - load_local_folder_page_tree_snapshot_for_scope(root_uri, Some(parent_relative_path)) + load_local_folder_page_tree_snapshot_for_scope(root_uri, Some(parent_relative_path), None) +} + +pub fn load_local_folder_page_tree_scope_snapshot_with_reveal( + root_uri: &str, + parent_relative_path: &str, + reveal_document_id: Option<&str>, +) -> Result { + let reveal_relative_path = reveal_document_id + .and_then(local_markdown_relative_path_from_document_id) + .map(|path| path.replace('\\', "/")); + load_local_folder_page_tree_snapshot_for_scope( + root_uri, + Some(parent_relative_path), + reveal_relative_path.as_deref(), + ) } fn load_local_folder_page_tree_snapshot_for_scope( root_uri: &str, parent_relative_path: Option<&str>, + reveal_relative_path: Option<&str>, ) -> Result { #[cfg(test)] LOCAL_PAGE_TREE_SNAPSHOT_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -2966,12 +3118,17 @@ fn load_local_folder_page_tree_snapshot_for_scope( "PageTree scope parentRelativePath 必须指向目录", )); } - let watch_revision = if parent_relative_path.is_empty() { - local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)? - } else { - local_folder_watch_revision_for_directory(&canonical_root, &scan_root, &root_source_uri)? - }; - let cache_key = format!("{root_source_uri}\n{parent_relative_path}"); + // P0.5: shallow revision for the scan directory only (Sidex-style). Full-tree + // recursive hashing of 50k+ entries was the dominant SSR TTFB cost on large + // local workspaces; nested changes invalidate when that parent scope is loaded. + let watch_revision = + local_folder_watch_revision_shallow_for_directory(&scan_root, &root_source_uri)?; + let reveal_relative_path = reveal_relative_path + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_matches('/').replace('\\', "/")) + .unwrap_or_default(); + let cache_key = format!("{root_source_uri}\n{parent_relative_path}\n{reveal_relative_path}"); if let Ok(cache) = local_page_tree_snapshot_cache().lock() { if let Some(entry) = cache.get(&cache_key) { if entry.watch_revision.revision == watch_revision.revision @@ -2986,17 +3143,43 @@ fn load_local_folder_page_tree_snapshot_for_scope( let mut rows = Vec::new(); #[cfg(test)] LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - scan_markdown_page_tree( + #[cfg(test)] + { + if let Ok(mut loads) = LOCAL_PAGE_TREE_SNAPSHOT_SCAN_TEST_LOADS_BY_KEY + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + { + *loads.entry(cache_key.clone()).or_insert(0) += 1; + } + } + let parent_depth = if parent_relative_path.is_empty() { + 0 + } else { + local_folder_relative_depth(parent_relative_path) + }; + // Sidex-aligned: one-level PageTree for SSR / children fetch; expand only reveal ancestors. + scan_markdown_page_tree_shallow( &canonical_root, &scan_root, None, - 0, + parent_depth, None, &metadata, &mut rows, &workspace_id, &root_source_uri, )?; + if !reveal_relative_path.is_empty() { + append_page_tree_reveal_rows( + &canonical_root, + parent_relative_path, + Some(reveal_relative_path.as_str()), + &root_source_uri, + &workspace_id, + &metadata, + &mut rows, + )?; + } let child_counts = rows .iter() .filter_map(|row| row.parent_node_id.as_ref()) @@ -3008,9 +3191,17 @@ fn load_local_folder_page_tree_snapshot_for_scope( }, ); for row in &mut rows { - row.child_count = child_counts.get(&row.node_id).copied().unwrap_or(0); - row.expandable = row.child_count > 0; - row.expanded_by_default = row.expandable && row.depth < 2; + let loaded_children = child_counts.get(&row.node_id).copied().unwrap_or(0); + row.child_count = loaded_children; + // Shallow SSR may mark expandable folders before children are loaded. + if loaded_children > 0 { + row.expandable = true; + } + // Only auto-expand rows that the reveal path marked (or already true from reveal). + // Do not expand every depth<2 node — that reintroduces deep first-paint trees. + if !row.expanded_by_default { + row.expanded_by_default = false; + } } let items = rows @@ -3044,6 +3235,7 @@ fn load_local_folder_page_tree_snapshot_for_scope( "sourceKind": "local_folder", "rootUri": root_source_uri, "parentRelativePath": parent_relative_path, + "lazy": true, "watchRevision": watch_revision, "items": items, }), @@ -3078,7 +3270,40 @@ pub fn local_folder_watch_revision(root_uri: &str) -> Result 0 { + if let Ok(cache) = local_folder_watch_revision_cache().lock() { + if let Some(entry) = cache.get(&cache_key) { + if now_ms.saturating_sub(entry.cached_at_ms) <= cache_ttl_ms { + return Ok(entry.revision.clone()); + } + } + } + } + let revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?; + if cache_ttl_ms > 0 { + if let Ok(mut cache) = local_folder_watch_revision_cache().lock() { + cache.insert( + cache_key, + LocalFolderWatchRevisionCacheEntry { + revision: revision.clone(), + cached_at_ms: now_ms, + }, + ); + if cache.len() > 32 { + if let Some(oldest) = cache.keys().next().cloned() { + cache.remove(&oldest); + } + } + } + } + Ok(revision) } pub fn resolve_local_markdown_page_aggregate( @@ -3128,8 +3353,11 @@ pub fn resolve_local_markdown_page_aggregate( &parsed.body, &attachment_paths, ); - let content = - infer_sibling_page_references_for_local_markdown(&canonical_root, &markdown_file.path, content); + let content = infer_sibling_page_references_for_local_markdown( + &canonical_root, + &markdown_file.path, + content, + ); let attachment_refs = parse_markdown_attachment_refs( &parsed.body, &markdown_file.path.display().to_string(), @@ -3720,6 +3948,8 @@ pub async fn stat_local_file( ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_local_workspace_read_access_with_state(&state, &context, &query.root_uri) .map_err(|error| error.with_context(&context))?; + crate::routes::vault_path::deny_if_vault_sensitive_relative_path(&query.path) + .map_err(|error| error.with_context(&context))?; let root_path = parse_file_root_uri(&query.root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( @@ -3964,6 +4194,7 @@ pub async fn write_local_resource( } fn resolve_local_open_path(root_uri: &str, relative_path: &str) -> Result { + crate::routes::vault_path::deny_if_vault_sensitive_relative_path(relative_path)?; let root_path = parse_file_root_uri(root_uri)?; let canonical_root = root_path.canonicalize().map_err(|error| { WebError::bad_request_code( @@ -4116,6 +4347,9 @@ pub(crate) fn write_local_folder_file_upload( )); } let target_relative_path = target_relative_path.trim(); + if !target_relative_path.is_empty() { + crate::routes::vault_path::deny_if_vault_sensitive_relative_path(target_relative_path)?; + } let requested = if target_relative_path.is_empty() { Path::new(".") } else { @@ -7031,14 +7265,40 @@ fn percent_decode_file_uri_path(value: &str) -> Result { } fn load_local_folder_metadata(root: &Path) -> Result { - Ok(LocalFolderMetadata { + let cache_key = root.to_string_lossy().to_string(); + let fingerprints = local_folder_metadata_fingerprints(root); + if let Ok(cache) = local_folder_metadata_cache().lock() { + if let Some(entry) = cache.get(&cache_key) { + if entry.fingerprints == fingerprints { + return Ok(entry.metadata.clone()); + } + } + } + + let metadata = LocalFolderMetadata { page_options: load_metadata_value_map(&root.join(".mnote").join("page-options.json"))?, trash_entries: load_trash_index_map(&root.join(".mnote").join("trash-index.json"))?, uploaded_assets: load_uploaded_asset_index_map( &root.join(".mnote").join("uploaded-assets.json"), )?, file_order: load_file_order_metadata(&root.join(".mnote").join("file-order.json"))?, - }) + }; + if let Ok(mut cache) = local_folder_metadata_cache().lock() { + cache.insert( + cache_key, + LocalFolderMetadataCacheEntry { + fingerprints, + metadata: metadata.clone(), + }, + ); + // Bound cache size for multi-workspace processes. + if cache.len() > 16 { + if let Some(oldest) = cache.keys().next().cloned() { + cache.remove(&oldest); + } + } + } + Ok(metadata) } pub(crate) fn load_local_trash_entries( @@ -7183,7 +7443,9 @@ fn write_trash_index_metadata( "version": 1, "entries": entries, }); - write_json_atomic(&path, &value) + write_json_atomic(&path, &value)?; + invalidate_local_folder_metadata_cache(root); + Ok(()) } fn write_uploaded_asset_index_metadata( @@ -7202,7 +7464,9 @@ fn write_uploaded_asset_index_metadata( "version": 1, "entries": entries, }); - write_json_atomic(&path, &value) + write_json_atomic(&path, &value)?; + invalidate_local_folder_metadata_cache(root); + Ok(()) } fn write_file_order_metadata( @@ -7221,7 +7485,9 @@ fn write_file_order_metadata( "version": 1, "parents": file_order, }); - write_json_atomic(&path, &value) + write_json_atomic(&path, &value)?; + invalidate_local_folder_metadata_cache(root); + Ok(()) } #[allow(dead_code)] @@ -7998,7 +8264,9 @@ fn apply_file_order_path_rewrite( write_file_order_metadata(root, &metadata.file_order) } -fn scan_markdown_page_tree( +/// One-level PageTree scan (Sidex-style): direct children only; nested dirs become +/// expandable stubs when they contain markdown descendants. +fn scan_markdown_page_tree_shallow( root: &Path, directory: &Path, parent_node_id: Option, @@ -8034,92 +8302,66 @@ fn scan_markdown_page_tree( .unwrap_or("page.md"), ); let page_id = local_markdown_path_page_id(&nested_relative); - let mut child_rows = Vec::new(); - let child_contains_markdown = scan_markdown_page_tree( - root, - &entry.path, - Some(page_id.clone()), - depth + 1, - Some(nested_relative.clone()), - metadata, - &mut child_rows, - workspace_id, - root_source_uri, - )?; - let child_count = child_rows.len() as u32; + let child_contains = + directory_contains_markdown_page(root, &entry.path, Some(nested_relative.as_str()))?; directory_rows.push(LocalFolderRow { node_id: page_id.clone(), - row_id: format!("local:page:{nested_relative}"), + row_id: format!("local:markdown:{nested_relative}"), parent_node_id: parent_node_id.clone(), title: parsed.title, depth, position: position as u32, row_kind: "document".to_string(), - icon_hint: "markdown".to_string(), - relative_path: nested_relative, + icon_hint: "document".to_string(), + relative_path: nested_relative.clone(), source_uri: file_uri_for_path(&nested_main), - child_count, - expandable: child_contains_markdown, - expanded_by_default: child_contains_markdown && depth < 2, + child_count: 0, + expandable: child_contains, + expanded_by_default: false, document_id: Some(page_id), capabilities: local_entry_capabilities(&entry), workspace_id: workspace_id.to_string(), root_source_uri: root_source_uri.to_string(), index_status: None, }); - directory_rows.extend(child_rows); contains_markdown = true; continue; } - // VSCode 风格 loose Markdown 也可作为页面节点管理; - // 若同级存在同名目录,该目录中的 Markdown 作为该页面的子页面投影。 let sibling_md = directory.join(format!("{}.md", entry.file_name)); let has_sibling_md = sibling_md.exists() && sibling_md.starts_with(root) && sibling_md.is_file(); - let parent_for_children = if has_sibling_md { - let sibling_relative = normalize_relative_path(root, &sibling_md)?; - Some(local_markdown_path_page_id(&sibling_relative)) - } else { - Some(local_directory_group_id(&entry.relative_path)) - }; - let mut child_rows = Vec::new(); - let child_contains_markdown = scan_markdown_page_tree( - root, - &entry.path, - parent_for_children, - depth + 1, - None, - metadata, - &mut child_rows, - workspace_id, - root_source_uri, - )?; - if child_contains_markdown { - if !has_sibling_md { - directory_rows.push(LocalFolderRow { - node_id: local_directory_group_id(&entry.relative_path), - row_id: format!("local:page-group:{}", entry.relative_path), - parent_node_id: parent_node_id.clone(), - title: entry.file_name.clone(), - depth, - position: position as u32, - row_kind: "document".to_string(), - icon_hint: "folder".to_string(), - relative_path: entry.relative_path.clone(), - source_uri: file_uri_for_path(&entry.path), - child_count: 0, - expandable: true, - expanded_by_default: depth < 2, - document_id: None, - capabilities: local_entry_capabilities(&entry), - workspace_id: workspace_id.to_string(), - root_source_uri: root_source_uri.to_string(), - index_status: None, - }); - } - directory_rows.extend(child_rows); - contains_markdown = true; + let child_contains = directory_contains_markdown_page(root, &entry.path, None)?; + if !child_contains { + continue; } + if !has_sibling_md { + directory_rows.push(LocalFolderRow { + node_id: local_directory_group_id(&entry.relative_path), + row_id: format!("local:page-group:{}", entry.relative_path), + parent_node_id: parent_node_id.clone(), + title: entry.file_name.clone(), + depth, + position: position as u32, + row_kind: "document".to_string(), + icon_hint: "folder".to_string(), + relative_path: entry.relative_path.clone(), + source_uri: file_uri_for_path(&entry.path), + child_count: 0, + expandable: true, + expanded_by_default: false, + document_id: None, + capabilities: local_entry_capabilities(&entry), + workspace_id: workspace_id.to_string(), + root_source_uri: root_source_uri.to_string(), + index_status: None, + }); + } else if let Some(sibling_row) = directory_rows + .iter_mut() + .find(|row| row.relative_path == normalize_relative_path(root, &sibling_md).unwrap_or_default()) + { + sibling_row.expandable = true; + } + contains_markdown = true; continue; } let uploaded_asset = metadata.uploaded_assets.get(&entry.relative_path); @@ -8144,37 +8386,36 @@ fn scan_markdown_page_tree( root_source_uri: root_source_uri.to_string(), index_status: None, }); + contains_markdown = true; continue; } if is_markdown_file(&entry.file_name) { - if local_ocr::is_local_ocr_sidecar_relative_path(&entry.relative_path) { - continue; - } let markdown = fs::read_to_string(&entry.path).unwrap_or_default(); let parsed = parse_markdown_page(&markdown, &entry.file_name); let page_id = local_markdown_path_page_id(&entry.relative_path); - let md_stem = entry + let stem = entry .path .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - let sibling_dir = directory.join(&md_stem); - let has_sibling_dir = sibling_dir.is_dir() && sibling_dir.starts_with(root); + .and_then(|name| name.to_str()) + .unwrap_or_default(); + let sibling_dir = directory.join(stem); + let expandable = sibling_dir.is_dir() + && !sibling_dir.is_symlink() + && directory_contains_markdown_page(root, &sibling_dir, None)?; directory_rows.push(LocalFolderRow { node_id: page_id.clone(), - row_id: format!("local:page:{}", entry.relative_path), + row_id: format!("local:markdown:{}", entry.relative_path), parent_node_id: parent_node_id.clone(), title: parsed.title, depth, position: position as u32, row_kind: "document".to_string(), - icon_hint: "markdown".to_string(), + icon_hint: "document".to_string(), relative_path: entry.relative_path.clone(), source_uri: file_uri_for_path(&entry.path), child_count: 0, - expandable: has_sibling_dir, - expanded_by_default: has_sibling_dir && depth < 2, + expandable, + expanded_by_default: false, document_id: Some(page_id), capabilities: local_entry_capabilities(&entry), workspace_id: workspace_id.to_string(), @@ -8188,6 +8429,220 @@ fn scan_markdown_page_tree( Ok(contains_markdown) } +fn directory_contains_markdown_page( + root: &Path, + directory: &Path, + skip_markdown_relative_path: Option<&str>, +) -> Result { + if !directory.is_dir() { + return Ok(false); + } + let mut stack = vec![directory.to_path_buf()]; + while let Some(current) = stack.pop() { + let entries = match fs::read_dir(¤t) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); + let relative_path = match normalize_relative_path(root, &path) { + Ok(value) => value, + Err(_) => continue, + }; + if should_ignore_entry(&relative_path, &file_name) { + continue; + } + if skip_markdown_relative_path + .map(|skip| skip == relative_path) + .unwrap_or(false) + { + if path.is_dir() { + stack.push(path); + } + continue; + } + if path.is_file() && is_markdown_file(&file_name) { + return Ok(true); + } + if path.is_dir() { + if nested_bundle_main_markdown(&path).is_some() { + return Ok(true); + } + stack.push(path); + } + } + } + Ok(false) +} + +fn append_page_tree_reveal_rows( + root: &Path, + parent_relative_path: &str, + reveal_relative_path: Option<&str>, + root_source_uri: &str, + workspace_id: &str, + metadata: &LocalFolderMetadata, + rows: &mut Vec, +) -> Result<(), WebError> { + let Some(reveal_relative_path) = reveal_relative_path + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + let parent_relative_path = parent_relative_path + .trim() + .trim_matches('/') + .replace('\\', "/"); + let reveal_relative_path = reveal_relative_path + .trim() + .trim_matches('/') + .replace('\\', "/"); + if !parent_relative_path.is_empty() + && reveal_relative_path != parent_relative_path + && !reveal_relative_path.starts_with(&format!("{parent_relative_path}/")) + { + return Ok(()); + } + let mut ancestors = ancestor_directories_for_relative_path(&reveal_relative_path); + if let Some(bundle_parent) = same_name_markdown_bundle_parent(&reveal_relative_path) { + ancestors.retain(|ancestor| ancestor != &bundle_parent); + } + if !parent_relative_path.is_empty() { + ancestors.retain(|ancestor| { + ancestor != &parent_relative_path + && ancestor.starts_with(&format!("{parent_relative_path}/")) + }); + } + if ancestors.is_empty() { + // Still expand the leaf page's own parent if it is a page group at root of scan. + if let Some(parent) = Path::new(&reveal_relative_path) + .parent() + .map(|p| { + p.components() + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/") + }) + .filter(|p| !p.is_empty()) + { + if parent_relative_path.is_empty() || parent == parent_relative_path { + // Mark existing parent row expanded if present. + for row in rows.iter_mut() { + if row.relative_path == parent { + row.expanded_by_default = true; + row.expandable = true; + } + } + } + } + return Ok(()); + } + let mut seen = rows + .iter() + .map(|row| row.row_id.clone()) + .collect::>(); + for ancestor in &ancestors { + let directory = resolve_metadata_relative_path(root, ancestor)?; + if !directory.is_dir() { + continue; + } + let depth = local_folder_relative_depth(ancestor); + // Parent id for children of this ancestor directory. + let parent_node_id = if let Some(sibling_md) = ancestor_sibling_markdown_page_id(root, ancestor) + { + Some(sibling_md) + } else { + Some(local_directory_group_id(ancestor)) + }; + // Ensure the ancestor group/page row itself is expanded. + for row in rows.iter_mut() { + if row.relative_path == *ancestor + || row.node_id == local_directory_group_id(ancestor) + || row.document_id.as_deref() + == parent_node_id + .as_deref() + .filter(|id| id.starts_with("local-md:")) + { + row.expanded_by_default = true; + row.expandable = true; + } + } + let mut child_rows = Vec::new(); + scan_markdown_page_tree_shallow( + root, + &directory, + parent_node_id, + depth + 1, + None, + metadata, + &mut child_rows, + workspace_id, + root_source_uri, + )?; + for mut row in child_rows { + if seen.contains(&row.row_id) { + continue; + } + // Expand intermediate ancestors that still sit on the reveal path. + if ancestors.iter().any(|a| a == &row.relative_path) + || row.relative_path == reveal_relative_path + || ancestors + .iter() + .any(|a| reveal_relative_path.starts_with(&format!("{a}/")) && row.relative_path == *a) + { + row.expanded_by_default = true; + } + if reveal_relative_path.starts_with(&format!("{}/", row.relative_path)) + || ancestors.iter().any(|a| a == &row.relative_path) + { + row.expanded_by_default = true; + row.expandable = true; + } + seen.insert(row.row_id.clone()); + rows.push(row); + } + } + // Mark the revealed document row expanded=false but ensure parent expanded. + for row in rows.iter_mut() { + if row.relative_path == reveal_relative_path { + row.expanded_by_default = false; + } + if ancestors.iter().any(|a| a == &row.relative_path) + || row.node_id == local_directory_group_id( + ancestors + .last() + .map(String::as_str) + .unwrap_or_default(), + ) + { + row.expanded_by_default = true; + row.expandable = true; + } + } + Ok(()) +} + +fn ancestor_sibling_markdown_page_id(root: &Path, ancestor_relative: &str) -> Option { + let parent = Path::new(ancestor_relative).parent()?; + let name = Path::new(ancestor_relative).file_name()?.to_str()?; + let sibling = if parent.as_os_str().is_empty() { + root.join(format!("{name}.md")) + } else { + root.join(parent).join(format!("{name}.md")) + }; + if sibling.is_file() { + let relative = normalize_relative_path(root, &sibling).ok()?; + return Some(local_markdown_path_page_id(&relative)); + } + None +} + fn find_markdown_by_page_id( root: &Path, metadata: &LocalFolderMetadata, @@ -8372,9 +8827,50 @@ fn local_folder_row_to_projection_item(row: &LocalFolderRow) -> Value { if let Ok(path_value) = serde_json::to_value(&workspace_path) { item["resourceMeta"]["workspacePath"] = path_value; } + if !row.relative_path.trim().is_empty() { + item["relativePath"] = Value::String(row.relative_path.clone()); + if row.expandable { + item["expandRelativePath"] = + Value::String(page_tree_expand_relative_path_for_row(row)); + } + } item } +/// Directory scope used when the browser lazy-expands a page-tree row. +fn page_tree_expand_relative_path_for_row(row: &LocalFolderRow) -> String { + let rp = row.relative_path.trim().trim_matches('/').replace('\\', "/"); + if rp.is_empty() { + return String::new(); + } + // Folder / page-group rows already point at the expand directory. + if row.document_id.is_none() + || row.icon_hint == "folder" + || row.row_id.starts_with("local:page-group:") + || row.node_id.starts_with("local-dir:") + { + return rp; + } + let lower = rp.to_ascii_lowercase(); + if !lower.ends_with(".md") { + return rp; + } + let stem = &rp[..rp.len().saturating_sub(3)]; + if let Some((parent, file)) = rp.rsplit_once('/') { + let file_stem = file + .strip_suffix(".md") + .or_else(|| file.strip_suffix(".MD")) + .or_else(|| file.strip_suffix(".Md")) + .unwrap_or(file); + let parent_name = parent.rsplit_once('/').map(|(_, name)| name).unwrap_or(parent); + if parent_name == file_stem { + return parent.to_string(); + } + return stem.to_string(); + } + stem.to_string() +} + fn local_node_id(relative_path: &str) -> String { format!("local:node:{relative_path}") } @@ -8479,6 +8975,7 @@ fn local_folder_watch_revision_for_root( local_folder_watch_revision_for_directory(root, root, root_source_uri) } +/// Full recursive revision (live events / public API). Prefer shallow for SSR snapshots. fn local_folder_watch_revision_for_directory( root: &Path, directory: &Path, @@ -8536,6 +9033,64 @@ fn local_folder_watch_revision_for_directory( }) } +/// One-level directory fingerprint for PageTree/FileTree snapshot cache keys. +/// Avoids walking entire workspace trees on every SSR home paint. +fn local_folder_watch_revision_shallow_for_directory( + directory: &Path, + root_source_uri: &str, +) -> Result { + let mut hasher = DefaultHasher::new(); + let mut entry_count = 0usize; + let mut latest_modified_ms = 0u128; + directory.hash(&mut hasher); + let read_dir = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) => { + return Err(WebError::bad_request_code( + "local_folder_unavailable", + format!("无法读取本地目录: {error}"), + )); + } + }; + let mut names = Vec::new(); + for entry in read_dir { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with('.') { + continue; + } + names.push(file_name.clone()); + file_name.hash(&mut hasher); + if let Ok(meta) = entry.metadata() { + meta.is_dir().hash(&mut hasher); + meta.len().hash(&mut hasher); + if let Ok(modified) = meta.modified() { + if let Ok(delta) = modified.duration_since(UNIX_EPOCH) { + let modified_ms = delta.as_millis(); + modified_ms.hash(&mut hasher); + if modified_ms > latest_modified_ms { + latest_modified_ms = modified_ms; + } + } + } + } + entry_count += 1; + } + names.sort(); + for name in names { + name.hash(&mut hasher); + } + Ok(LocalFolderWatchRevision { + root_uri: root_source_uri.to_string(), + revision: format!("{:016x}", hasher.finish()), + entry_count, + latest_modified_ms, + }) +} + fn file_uri_for_path(path: &Path) -> String { format!("file://{}", path.display()) } @@ -10094,8 +10649,11 @@ mod tests { get_share_links, get_user_access_policy, initialize_local_page_id, initialize_local_workspace_for_actor, load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal, - load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot, - local_folder_watch_revision, local_markdown_path_page_id, + load_local_folder_page_tree_scope_snapshot, + load_local_folder_metadata, load_local_folder_page_tree_snapshot, + load_local_folder_page_tree_snapshot_with_reveal, local_folder_watch_revision, + invalidate_local_folder_metadata_cache, + set_local_folder_watch_revision_cache_ttl_ms_for_test, local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id, open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate, save_local_markdown_page, @@ -10223,17 +10781,81 @@ mod tests { init_workspace(&root); std::fs::write(root.join("page.md"), "# Page\n").expect("write md"); let root_uri = format!("file://{}", root.display()); - super::reset_local_page_tree_snapshot_test_loads(); let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot"); let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot"); assert_eq!(first.projection, second.projection); - assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 1); + assert_eq!( + super::local_page_tree_snapshot_scan_test_loads_for_key(&root_uri, ""), + 1 + ); std::fs::write(root.join("next.md"), "# Next\n").expect("write next"); let third = load_local_folder_page_tree_snapshot(&root_uri).expect("third snapshot"); assert_ne!(second.projection, third.projection); - assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 2); + assert_eq!( + super::local_page_tree_snapshot_scan_test_loads_for_key(&root_uri, ""), + 2 + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_folder_page_tree_snapshot_is_shallow_and_reveal_expands_ancestors() { + let root = temp_root("mnote-page-tree-shallow-reveal"); + init_workspace(&root); + std::fs::create_dir_all(root.join("design").join("process")).expect("create nested"); + std::fs::create_dir_all(root.join("notes").join("deep")).expect("create notes deep"); + std::fs::write(root.join("root.md"), "# Root\n").expect("write root md"); + std::fs::write(root.join("design").join("a.md"), "# Design A\n").expect("write design a"); + std::fs::write( + root.join("design").join("process").join("deep.md"), + "# Deep\n", + ) + .expect("write deep"); + std::fs::write(root.join("notes").join("deep").join("n.md"), "# Notes\n") + .expect("write notes"); + let root_uri = format!("file://{}", root.display()); + + let shallow = load_local_folder_page_tree_snapshot(&root_uri).expect("shallow snapshot"); + let shallow_json = shallow.projection.to_string(); + assert!( + shallow_json.contains("root.md"), + "root markdown should appear: {shallow_json}" + ); + assert!( + shallow_json.contains("design") || shallow_json.contains("page-group:design"), + "design folder/group should appear: {shallow_json}" + ); + assert!( + !shallow_json.contains("deep.md"), + "nested deep.md must not appear without reveal: {shallow_json}" + ); + assert!( + !shallow_json.contains("notes/deep") && !shallow_json.contains("n.md"), + "unrelated deep notes must not appear: {shallow_json}" + ); + + let reveal_doc = local_markdown_path_page_id("design/process/deep.md"); + let revealed = load_local_folder_page_tree_snapshot_with_reveal( + &root_uri, + Some(reveal_doc.as_str()), + ) + .expect("reveal snapshot"); + let revealed_json = revealed.projection.to_string(); + assert!( + revealed_json.contains("deep.md"), + "reveal path must include deep.md: {revealed_json}" + ); + assert!( + revealed_json.contains("process") || revealed_json.contains("a.md"), + "reveal ancestors must be present: {revealed_json}" + ); + assert!( + !revealed_json.contains("notes/deep") && !revealed_json.contains("\"n.md\""), + "reveal must not dump unrelated branches: {revealed_json}" + ); let _ = std::fs::remove_dir_all(&root); } @@ -10248,7 +10870,6 @@ mod tests { std::fs::write(root.join("notes").join("outside.md"), "# Outside\n") .expect("write outside"); let root_uri = format!("file://{}", root.display()); - super::reset_local_page_tree_snapshot_test_loads(); let first = load_local_folder_page_tree_scope_snapshot(&root_uri, "design").expect("first scope"); @@ -10257,7 +10878,10 @@ mod tests { load_local_folder_page_tree_scope_snapshot(&root_uri, "design").expect("second scope"); assert_eq!(first.projection, second.projection); - assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 1); + assert_eq!( + super::local_page_tree_snapshot_scan_test_loads_for_key(&root_uri, "design"), + 1 + ); let _ = std::fs::remove_dir_all(&root); } @@ -10485,6 +11109,8 @@ mod tests { #[test] fn local_folder_watch_revision_changes_for_visible_files_only() { + // Disable TTL cache so this contract test observes filesystem changes immediately. + set_local_folder_watch_revision_cache_ttl_ms_for_test(0); let root = temp_root("mnote-local-watch-revision"); std::fs::create_dir_all(root.join(".mnote")).expect("create metadata"); std::fs::create_dir_all(root.join("docs")).expect("create docs"); @@ -10512,6 +11138,7 @@ mod tests { assert_ne!(initial.revision, updated.revision); assert!(updated.entry_count >= 1); + set_local_folder_watch_revision_cache_ttl_ms_for_test(2_000); let _ = std::fs::remove_dir_all(&root); } @@ -10699,8 +11326,8 @@ fn main() {} }, ) .expect("upload second md asset"); - assert_eq!(first["sourcePath"], "notes.md"); - assert_eq!(second["sourcePath"], "notes-1.md"); + assert_eq!(first["sourcePath"], ".assets/file/notes.md"); + assert_eq!(second["sourcePath"], ".assets/file/notes-1.md"); save_local_markdown_page( &root_uri, @@ -10708,8 +11335,8 @@ fn main() {} None, &json!([ {"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Page"}]}, - {"type":"media","props":{"name":"notes.md","sourcePath":"notes.md"}}, - {"type":"media","props":{"name":"notes-1.md","sourcePath":"notes-1.md"}} + {"type":"media","props":{"name":"notes.md","sourcePath":".assets/file/notes.md"}}, + {"type":"media","props":{"name":"notes-1.md","sourcePath":".assets/file/notes-1.md"}} ]), ) .expect("save uploaded md links"); @@ -10730,7 +11357,10 @@ fn main() {} .to_string() }) .collect::>(); - assert_eq!(media_paths, vec!["notes.md", "notes-1.md"]); + assert_eq!( + media_paths, + vec![".assets/file/notes.md", ".assets/file/notes-1.md"] + ); let _ = std::fs::remove_dir_all(&root); } @@ -10759,8 +11389,8 @@ fn main() {} "local-md:{}", encode_local_id_segment("完结项目/完结项目.md") ); - let aggregate = resolve_local_markdown_page_aggregate(&root_uri, &document_id) - .expect("aggregate"); + let aggregate = + resolve_local_markdown_page_aggregate(&root_uri, &document_id).expect("aggregate"); let blocks = aggregate.body.content.as_array().expect("blocks"); assert_eq!(blocks[1]["type"], "page_reference"); @@ -10830,7 +11460,7 @@ fn main() {} .expect("save inline attachment link"); let saved = std::fs::read_to_string(root.join("Page").join("Page.md")).expect("read md"); - assert_eq!(asset["sourcePath"], "notes.md"); + assert_eq!(asset["sourcePath"], ".assets/file/notes.md"); assert!( saved.contains("/api/local-folder/files/open"), "开发态不再把旧 runtime open URL 自动迁移为标准 href" @@ -12701,11 +13331,14 @@ fn main() {} }, ) .expect("upload second linked markdown"); - assert_eq!(resource["sourcePath"], "resource.md"); - assert_eq!(first["sourcePath"], "notes.md"); - assert_eq!(second["sourcePath"], "notes-1.md"); + assert_eq!(resource["sourcePath"], ".assets/file/resource.md"); + assert_eq!(first["sourcePath"], ".assets/file/notes.md"); + assert_eq!(second["sourcePath"], ".assets/file/notes-1.md"); std::fs::write( - root.join("Page").join("resource.md"), + root.join("Page") + .join(".assets") + .join("file") + .join("resource.md"), "# Resource\n\n[notes.md](notes.md)\n\n[notes-1.md](notes-1.md)\n", ) .expect("write resource links"); @@ -12746,7 +13379,7 @@ fn main() {} Extension(context), Query(LocalResourceReadQuery { root_uri: root_uri.clone(), - path: "Page/resource.md".into(), + path: "Page/.assets/file/resource.md".into(), }), ) .await @@ -14098,11 +14731,10 @@ fn main() {} ) .expect("write Child.md"); - // 读取页面树。 + // Root scope 是 Sidex-style 一层扫描:只暴露 Root 页面 stub,不内嵌 Child。 let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("load page tree"); let items = snapshot.projection["items"].as_array().expect("items"); - // 找到 Root 页面节点和 Child 页面节点。标题来自文件名,不来自正文 H1。 let root_node = items .iter() .find(|item| { @@ -14110,22 +14742,20 @@ fn main() {} && item["resourceMeta"]["resourceKind"].as_str() == Some("document") }) .expect("Root page node"); - - let child_node = items - .iter() - .find(|item| { - item["title"].as_str() == Some("Child") - && item["resourceMeta"]["resourceKind"].as_str() == Some("document") - }) - .expect("Child page node"); - - // Child 的父节点应是 Root 的 documentId。 let root_doc_id = root_node["resourceMeta"]["documentId"] .as_str() .or_else(|| root_node["nodeId"].as_str()) .expect("root documentId"); - assert_eq!(child_node["parentNodeId"].as_str(), Some(root_doc_id)); assert_eq!(root_doc_id, "local-md:Root~2FRoot.md"); + assert_eq!( + root_node["expandable"].as_bool(), + Some(true), + "Root with markdown descendants must be expandable" + ); + assert!( + items.iter().all(|item| item["title"].as_str() != Some("Child")), + "shallow root snapshot must not embed nested Child page: {items:?}" + ); // Root bundle 目录不应额外生成 page-group 行。 let page_group_for_root = items.iter().find(|item| { @@ -14138,6 +14768,39 @@ fn main() {} "should not have page-group for Root" ); + // Expand path for Root/Root.md is the bundle directory "Root" (Sidex-style). + // Scope projection returns one shallow level; browser rewrites parentNodeId onto + // the expanded row when patching, so server parentNodeId is not authoritative here. + let child_snapshot = + load_local_folder_page_tree_scope_snapshot(&root_uri, "Root").expect("load Root children"); + let child_items = child_snapshot.projection["items"] + .as_array() + .expect("child items"); + let child_node = child_items + .iter() + .find(|item| { + item["title"].as_str() == Some("Child") + && item["resourceMeta"]["resourceKind"].as_str() == Some("document") + }) + .expect("Child page node under Root scope"); + let child_relative = child_node["resourceMeta"]["workspacePath"]["relativePath"] + .as_str() + .or_else(|| child_node["relativePath"].as_str()) + .or_else(|| { + child_node["resourceMeta"]["extra"]["source"]["relativePath"].as_str() + }); + assert!( + child_relative == Some("Root/Child/Child.md") + || child_node["nodeId"].as_str() == Some("local-md:Root~2FChild~2FChild.md") + || child_node["resourceMeta"]["documentId"].as_str() + == Some("local-md:Root~2FChild~2FChild.md"), + "Child under Root scope must resolve to nested bundle path: {child_node}" + ); + assert_eq!( + child_snapshot.projection["parentRelativePath"].as_str(), + Some("Root") + ); + let _ = std::fs::remove_dir_all(&root); } @@ -14652,4 +15315,69 @@ fn main() {} let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn local_folder_metadata_cache_reuses_uploaded_assets_until_fingerprint_changes() { + let root = temp_root("mnote-local-metadata-cache"); + std::fs::create_dir_all(root.join(".mnote")).expect("create .mnote"); + std::fs::write( + root.join(".mnote").join("uploaded-assets.json"), + r#"{"version":1,"entries":{"a/file.pdf":{"documentId":"doc-1","relativePath":"a/file.pdf","fileName":"file.pdf","createdAtMs":1}}}"#, + ) + .expect("write assets"); + std::fs::write( + root.join(".mnote").join("file-order.json"), + r#"{"version":1,"parents":{"":["a"]}}"#, + ) + .expect("write order"); + + let first = load_local_folder_metadata(&root).expect("first metadata load"); + assert_eq!(first.uploaded_assets.len(), 1); + // Root parent key is normalized to "." (see normalize_file_order_parent_key). + assert_eq!( + first.file_order.get(".").map(Vec::as_slice), + Some([String::from("a")].as_slice()) + ); + + // Same fingerprints should hit process cache (pointer equality not required; content match). + let second = load_local_folder_metadata(&root).expect("second metadata load"); + assert_eq!( + first.uploaded_assets.keys().collect::>(), + second.uploaded_assets.keys().collect::>() + ); + + // Fingerprint change (content rewrite) must refresh cache. + std::fs::write( + root.join(".mnote").join("uploaded-assets.json"), + r#"{"version":1,"entries":{"a/file.pdf":{"documentId":"doc-1","relativePath":"a/file.pdf","fileName":"file.pdf","createdAtMs":1},"b/file.pdf":{"documentId":"doc-2","relativePath":"b/file.pdf","fileName":"file.pdf","createdAtMs":2}}}"#, + ) + .expect("rewrite assets"); + let third = load_local_folder_metadata(&root).expect("third metadata load"); + assert_eq!(third.uploaded_assets.len(), 2); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn local_folder_watch_revision_cache_hits_within_ttl() { + set_local_folder_watch_revision_cache_ttl_ms_for_test(60_000); + let root = temp_root("mnote-local-watch-revision-cache"); + std::fs::create_dir_all(root.join("docs")).expect("create docs"); + std::fs::write(root.join("docs").join("page.md"), "# One\n").expect("write md"); + let root_uri = format!("file://{}", root.display()); + + let first = local_folder_watch_revision(&root_uri).expect("first revision"); + // Mutate tree immediately; TTL should still serve previous revision. + std::fs::write(root.join("docs").join("page.md"), "# Two\n").expect("update md"); + let cached = local_folder_watch_revision(&root_uri).expect("cached revision"); + assert_eq!(first.revision, cached.revision); + + // Explicit invalidation path (same as metadata writes) must force recompute. + invalidate_local_folder_metadata_cache(&root); + let after_invalidate = local_folder_watch_revision(&root_uri).expect("fresh revision"); + assert_ne!(first.revision, after_invalidate.revision); + + set_local_folder_watch_revision_cache_ttl_ms_for_test(2_000); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/rust/crates/mnote-web/src/routes/local_markdown_parser.rs b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs index 9acd73f5..4a32a4b0 100644 --- a/rust/crates/mnote-web/src/routes/local_markdown_parser.rs +++ b/rust/crates/mnote-web/src/routes/local_markdown_parser.rs @@ -197,7 +197,21 @@ fn parse_markdown_attachment_link_with_paths( { return None; } - let target_path = std::path::Path::new(target); + if page_reference_from_target(target, label).is_some() + && !attachment_paths.contains(target) + && !is_local_markdown_asset_href(target) + { + return None; + } + let target_without_fragment = target + .split_once('#') + .map(|(path, _)| path) + .unwrap_or(target); + let target_path_part = target_without_fragment + .split_once('?') + .map(|(path, _)| path) + .unwrap_or(target_without_fragment); + let target_path = std::path::Path::new(target_path_part); let extension = target_path.extension().and_then(|value| value.to_str())?; if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")) && !attachment_paths.contains(target) @@ -320,7 +334,40 @@ fn extract_html_attr<'a>(fragment: &'a str, name: &str) -> Option<(&'a str, usiz fn should_collect_attachment_href(raw_href: &str) -> bool { let trimmed = raw_href.trim(); - !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("mailto:") + if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("mailto:") { + return false; + } + page_reference_from_target(trimmed, "").is_none() || is_local_markdown_asset_href(trimmed) +} + +fn is_local_markdown_asset_href(raw_href: &str) -> bool { + let trimmed = raw_href + .trim() + .strip_prefix('<') + .and_then(|value| value.strip_suffix('>')) + .unwrap_or(raw_href.trim()); + let without_fragment = trimmed + .split_once('#') + .map(|(path, _)| path) + .unwrap_or(trimmed); + let without_query = without_fragment + .split_once('?') + .map(|(path, _)| path) + .unwrap_or(without_fragment); + let normalized = without_query + .strip_prefix("./") + .unwrap_or(without_query) + .replace('\\', "/"); + let extension = Path::new(&normalized) + .extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()); + if !matches!(extension.as_deref(), Some("md" | "markdown")) { + return false; + } + normalized + .split('/') + .any(|segment| segment == ".assets" || segment.ends_with(".assets")) } fn build_attachment_ref( @@ -686,6 +733,9 @@ fn append_ast_paragraph<'a>( blocks.push(MarkdownBlock::PageReference { title, source_path }); return; } + if append_paragraph_attachment_sequence(node, blocks, attachment_paths) { + return; + } if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node, attachment_paths) { @@ -716,6 +766,9 @@ fn append_ast_list_item<'a>( if children.next().is_none() && matches!(paragraph.data.borrow().value, NodeValue::Paragraph) { + if append_paragraph_attachment_sequence(paragraph, blocks, attachment_paths) { + return; + } if let Some((title, source_path)) = paragraph_page_reference(paragraph) { blocks.push(MarkdownBlock::PageReference { title, source_path }); return; @@ -724,13 +777,49 @@ fn append_ast_list_item<'a>( } } let mut content = Vec::new(); + let mut emitted_child_blocks = false; for child in node.children() { match child.data.borrow().value.clone() { - NodeValue::Paragraph => content.extend(collect_inline_children(child)), - _ => append_ast_block(child, blocks, attachment_paths), + NodeValue::Paragraph => { + let mut paragraph_blocks = Vec::new(); + if append_paragraph_attachment_sequence( + child, + &mut paragraph_blocks, + attachment_paths, + ) { + if !content.is_empty() { + push_list_item_content_block(blocks, ordered, is_task, checked, content); + content = Vec::new(); + } + blocks.extend(paragraph_blocks); + emitted_child_blocks = true; + } else { + content.extend(collect_inline_children(child)); + } + } + _ => { + if !content.is_empty() { + push_list_item_content_block(blocks, ordered, is_task, checked, content); + content = Vec::new(); + } + append_ast_block(child, blocks, attachment_paths); + emitted_child_blocks = true; + } } } + if !content.is_empty() || !emitted_child_blocks { + push_list_item_content_block(blocks, ordered, is_task, checked, content); + } +} + +fn push_list_item_content_block( + blocks: &mut Vec, + ordered: bool, + is_task: bool, + checked: bool, + content: Vec, +) { if is_task { blocks.push(MarkdownBlock::Todo { checked, content }); } else if ordered { @@ -876,6 +965,39 @@ fn paragraph_attachment_media<'a>( link_attachment_media(first, attachment_paths) } +fn append_paragraph_attachment_sequence<'a>( + node: &'a AstNode<'a>, + blocks: &mut Vec, + attachment_paths: &BTreeSet, +) -> bool { + let mut parsed = Vec::::new(); + for child in node.children() { + match &child.data.borrow().value { + NodeValue::SoftBreak | NodeValue::LineBreak => continue, + NodeValue::Text(text) if text.as_ref().trim().is_empty() => continue, + NodeValue::Image(link) => parsed.push(MarkdownBlock::Image { + alt: collect_plain_text(child).trim().to_string(), + source_path: link.url.clone(), + }), + NodeValue::Link(_) => { + if let Some((name, source_path)) = link_attachment_media(child, attachment_paths) { + parsed.push(MarkdownBlock::Media { name, source_path }); + } else if let Some((title, source_path)) = link_page_reference(child) { + parsed.push(MarkdownBlock::PageReference { title, source_path }); + } else { + return false; + } + } + _ => return false, + } + } + if parsed.is_empty() { + return false; + } + blocks.extend(parsed); + true +} + fn paragraph_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { let mut children = node.children(); let first = children.next()?; @@ -982,6 +1104,9 @@ fn link_page_reference<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> { fn page_reference_from_target(target: &str, label: &str) -> Option<(String, String)> { let target = normalized_markdown_link_target(target)?; + if let Some(source_path) = local_markdown_relative_path_from_documents_target(target) { + return Some(page_reference_title_and_path(&source_path, label)); + } if target.is_empty() || target.starts_with("http://") || target.starts_with("https://") @@ -1004,20 +1129,59 @@ fn page_reference_from_target(target: &str, label: &str) -> Option<(String, Stri if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") { return None; } - let fallback_title = std::path::Path::new(path_part) + Some(page_reference_title_and_path(path_part, label)) +} + +fn page_reference_title_and_path(source_path: &str, label: &str) -> (String, String) { + let fallback_title = std::path::Path::new(source_path) .file_stem() .and_then(|value| value.to_str()) .unwrap_or("页面") .trim(); let title = label.trim(); - Some(( + ( if title.is_empty() { fallback_title.to_string() } else { title.to_string() }, - path_part.to_string(), - )) + source_path.to_string(), + ) +} + +fn local_markdown_relative_path_from_documents_target(target: &str) -> Option { + let normalized = target.trim(); + let path_and_query = if normalized.starts_with("/documents/") { + normalized + } else if let Some((_, rest)) = normalized.split_once("://") { + let slash = rest.find('/')?; + &rest[slash..] + } else { + return None; + }; + let path_part = path_and_query + .split_once('#') + .map(|(path, _)| path) + .unwrap_or(path_and_query) + .split_once('?') + .map(|(path, _)| path) + .unwrap_or(path_and_query); + let segment = path_part.strip_prefix("/documents/")?; + let decoded_segment = percent_decode_lossy(segment); + let local_id = decoded_segment.strip_prefix("local-md:")?; + let relative_path = percent_decode_lossy(&local_id.replace('~', "%")) + .trim_start_matches('/') + .to_string(); + if relative_path.is_empty() { + return None; + } + let extension = std::path::Path::new(&relative_path) + .extension() + .and_then(|value| value.to_str())?; + if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") { + return None; + } + Some(relative_path) } fn normalized_markdown_link_target(target: &str) -> Option<&str> { @@ -1363,6 +1527,28 @@ mod tests { assert_eq!(first["props"]["alt"].as_str(), Some("示例图片")); } + #[test] + fn markdown_consecutive_attachment_links_parse_as_blocks() { + let blocks = markdown_to_blocks( + "[身份证](.assets/身份证_李爱波.pdf)\n[本科证书](.assets/本科证书证明.pdf)\n![照片](.assets/photo.png)\n", + ); + let items = blocks.as_array().expect("blocks array"); + + assert_eq!(items.len(), 3); + assert_eq!(items[0]["type"].as_str(), Some("media")); + assert_eq!( + items[0]["props"]["sourcePath"].as_str(), + Some(".assets/身份证_李爱波.pdf") + ); + assert_eq!(items[1]["type"].as_str(), Some("media")); + assert_eq!( + items[1]["props"]["sourcePath"].as_str(), + Some(".assets/本科证书证明.pdf") + ); + assert_eq!(items[2]["type"].as_str(), Some("image")); + assert_eq!(items[2]["props"]["src"].as_str(), Some(".assets/photo.png")); + } + #[test] fn markdown_mindmap_link_parses_generated_mindmap_json_as_mindmap_block() { let blocks = markdown_to_blocks("[思维导图](mindmap-123456.json)\n"); @@ -1411,6 +1597,46 @@ mod tests { assert_eq!(first["props"]["sourcePath"].as_str(), Some("知识/知识.md")); } + #[test] + fn markdown_runtime_local_documents_url_parses_as_page_reference_block() { + let blocks = markdown_to_blocks( + "[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n", + ); + let first = blocks + .as_array() + .and_then(|items| items.first()) + .expect("first block"); + + assert_eq!(first["type"].as_str(), Some("page_reference")); + assert_eq!(first["props"]["title"].as_str(), Some("爱斯特完结项目")); + assert_eq!( + first["props"]["sourcePath"].as_str(), + Some("liaibo的个人空间/项目/完结项目/爱斯特完结项目/爱斯特完结项目.md") + ); + } + + #[test] + fn markdown_runtime_local_documents_url_is_not_attachment_ref() { + let root = std::env::temp_dir().join(format!( + "mnote-page-ref-attachment-filter-{}", + std::process::id() + )); + let owner_dir = root.join("liaibo的个人空间/项目/完结项目"); + std::fs::create_dir_all(&owner_dir).expect("create owner dir"); + let owner = owner_dir.join("完结项目.md"); + + let refs = parse_markdown_attachment_refs( + "[爱斯特完结项目](/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE~2F~E7~88~B1~E6~96~AF~E7~89~B9~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree)\n", + &owner.display().to_string(), + &format!("file://{}", root.display()), + ); + + assert!( + refs.is_empty(), + "内部页面链接不能进入 attachmentRefs: {refs:?}" + ); + } + #[test] fn markdown_attachment_refs_parse_standard_href_variants() { let root = std::env::temp_dir().join(format!( diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index c1a1dfe3..899c94b3 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -42,6 +42,9 @@ mod tree; mod tree_view_state; mod ui_debug; pub(crate) mod ui_preferences; +mod vault; +mod vault_path; +mod vault_store; pub(crate) mod web_shell; mod ws; @@ -75,6 +78,8 @@ pub fn build_router(state: AppState) -> Router { .route("/health", get(health::health)) .route("/", get(gateway::root_entry)) .route("/trash", get(gateway::trash_entry)) + .route("/vault", get(gateway::vault_entry)) + .route("/files", get(gateway::files_redirect_to_vault)) .route("/favicon.ico", get(gateway::favicon)) .route("/settings", get(gateway::settings_entry)) .route( @@ -370,6 +375,64 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/document-editor-adapter-runtime.js", get(web_shell::document_editor_adapter_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/vault-workbench-runtime.js", + get(web_shell::vault_workbench_runtime_asset), + ) + .route("/api/vault/ensure", post(vault::ensure)) + .route("/api/vault/reindex", post(vault::reindex)) + .route("/api/vault/list", get(vault::list)) + .route("/api/vault/items", post(vault::create_item)) + .route( + "/api/vault/items/{id}", + get(vault::get_item) + .patch(vault::update_item) + .delete(vault::delete_item), + ) + .route( + "/api/vault/items/{id}/restore", + post(vault::restore_item), + ) + .route("/api/vault/items/{id}/purge", post(vault::purge_item)) + .route( + "/api/vault/items/{id}/reveal", + post(vault::reveal_item), + ) + .route( + "/api/vault/items/{id}/resolve", + post(vault::resolve_item), + ) + .route( + "/api/vault/items/{id}/share-to-ai", + post(vault::share_to_ai), + ) + .route( + "/api/vault/items/{id}/unshare-from-ai", + post(vault::unshare_from_ai), + ) + .route("/api/vault/ai/list", get(vault::list_ai)) + .route("/api/vault/ai/items/{id}", get(vault::get_ai_item)) + .route( + "/api/vault/ai/items/{id}/resolve", + post(vault::resolve_ai_item), + ) + .route( + "/api/vault/ai/items/{id}/login", + post(vault::login_ai_item), + ) + .route( + "/api/vault/ai/items/{id}/session", + post(vault::put_ai_session), + ) + .route("/api/vault/cipher-book", get(vault::list_cipher_book)) + .route( + "/api/vault/cipher-book/{key}", + put(vault::put_cipher_key).delete(vault::delete_cipher_key), + ) + .route( + "/api/vault/cipher-book/{key}/reveal", + post(vault::reveal_cipher_key), + ) .route("/api/search/documents", post(search::documents)) .route( "/api/search/local-index/refresh", @@ -859,6 +922,10 @@ pub fn build_router(state: AppState) -> Router { "/api/documents/buffer-state/dirty", post(documents::mark_buffer_dirty), ) + .route( + "/api/documents/buffer-state/dirty:0", + post(documents::mark_buffer_dirty), + ) .route("/api/documents/purge", post(documents::purge)) .route("/api/documents/empty-trash", post(documents::empty_trash)) .route("/api/documents/title", post(documents::title)) @@ -1624,6 +1691,7 @@ mod tests { "/api/mnote-browser-runtime/document-slash-position-runtime.js", "/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js", "/api/mnote-browser-runtime/document-editor-adapter-runtime.js", + "/api/mnote-browser-runtime/vault-workbench-runtime.js", ] { let response = app(false) .oneshot( diff --git a/rust/crates/mnote-web/src/routes/page_ai_pi/constants.rs b/rust/crates/mnote-web/src/routes/page_ai_pi/constants.rs new file mode 100644 index 00000000..1c8ece79 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/page_ai_pi/constants.rs @@ -0,0 +1,43 @@ +//! Pi Page AI 常量与 schema 名(产品口径,非 spike)。 + +pub(super) const PI_LAB_VERSION: &str = "1.0.0"; +pub(super) const PI_LAB_PROVIDER: &str = "pi"; +pub(super) const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1"; +pub(super) const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1"; +pub(super) const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1"; +pub(super) const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1"; +pub(super) const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1"; +pub(super) const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1"; +pub(super) const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1"; +pub(super) const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1"; +pub(super) const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute"; +pub(super) const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1"; +pub(super) const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1"; +pub(super) const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1"; + +pub(super) const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini"; +pub(super) const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1"; +pub(super) const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token"; +pub(super) const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000; +pub(super) const PI_LAB_MAX_SESSIONS: usize = 16; +pub(super) const PI_LAB_RATE_WINDOW_MS: u128 = 10_000; +pub(super) const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4; +pub(super) const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12; +pub(super) const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40; +pub(super) const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust"; +pub(super) const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [ + "read", + "write", + "edit", + "bash", + "grep", + "find", + "ls", + "hashline_edit", +]; +pub(super) const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json"; +pub(super) const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000; +pub(super) const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024; +pub(super) const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024; +pub const PI_LAB_PROFILE: &str = "pi_lab"; +pub const PI_LAB_ACP_RUNTIME: &str = "pi"; diff --git a/rust/crates/mnote-web/src/routes/page_ai_pi/mod.rs b/rust/crates/mnote-web/src/routes/page_ai_pi/mod.rs new file mode 100644 index 00000000..704e819a --- /dev/null +++ b/rust/crates/mnote-web/src/routes/page_ai_pi/mod.rs @@ -0,0 +1,12 @@ +//! Pi Rust Page AI route module. +//! +//! - [`constants`]:版本 / schema / profile 常量 +//! - [`runtime`]:HTTP handlers、RPC、session 热缓存与桥接 +//! +//! 会话元数据权威源:Turso/libSQL control-plane。 +//! 进程内 `HashMap` 仅为热缓存;重启后由 `hydrate_session_from_control_plane` 恢复元数据。 + +mod constants; +mod runtime; + +pub use runtime::*; diff --git a/rust/crates/mnote-web/src/routes/page_ai_pi.rs b/rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs similarity index 94% rename from rust/crates/mnote-web/src/routes/page_ai_pi.rs rename to rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs index d9bfea97..6268debf 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_pi.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs @@ -1,7 +1,8 @@ -//! Pi-first Page AI Lab — MNote 托管的后端垂直切片。 +//! Pi Rust Page AI — MNote 托管的生产 Page AI 后端。 //! -//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端;OpenHub 与 Pi TS 已退役到 recycle 边界。 +//! 默认启用(`MNOTE_PAGE_AI_PI_LAB`);OpenHub 与 Pi TS 已退役到 recycle 边界。 //! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。 +//! 会话元数据 / run / tool event 持久化到 Turso/libSQL control-plane;进程内表仅作热缓存。 use crate::app::AppState; use crate::context::RequestContext; @@ -35,52 +36,13 @@ use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::{broadcast, oneshot, Mutex as AsyncMutex}; use tokio_stream::wrappers::BroadcastStream; -const PI_LAB_VERSION: &str = "0.1.0-pi-lab-spike"; -const PI_LAB_PROVIDER: &str = "pi"; -const PI_LAB_SCHEMA_STATUS: &str = "mnote.page_ai_pi.status.v1"; -const PI_LAB_SCHEMA_RECEIPT: &str = "mnote.page_ai_pi.tool_receipt.v1"; -const PI_LAB_SCHEMA_EVENT: &str = "mnote.page_ai_pi.event.v1"; -const PI_LAB_SCHEMA_STATE: &str = "mnote.page_ai_pi.state.v1"; -const PI_LAB_SCHEMA_COMPACT: &str = "mnote.page_ai_pi.compact.v1"; -const PI_LAB_SCHEMA_QUEUE_CONFIG: &str = "mnote.page_ai_pi.queue_config.v1"; -const PI_LAB_SCHEMA_RPC_COMMAND: &str = "mnote.page_ai_pi.rpc_command.v1"; -const PI_LAB_SCHEMA_DIAGNOSTICS: &str = "mnote.page_ai_pi.diagnostics.v1"; -const PI_LAB_DEFAULT_MODEL_PROVIDER: &str = "omniroute"; -const PI_LAB_SCHEMA_SESSION_TREE: &str = "mnote.page_ai_pi.session_tree.v1"; -const PI_LAB_SCHEMA_FORK: &str = "mnote.page_ai_pi.fork.v1"; -const PI_LAB_SCHEMA_ARTIFACT_DIFF: &str = "mnote.page_ai_pi.artifact_diff.v1"; - -const PI_LAB_DEFAULT_MODEL_ID: &str = "gpt-5.4-mini"; -const PI_LAB_DEFAULT_OMNIROUTE_BASE_URL: &str = "http://127.0.0.1:20128/v1"; -const HEADER_PI_LAB_BRIDGE_TOKEN: &str = "x-mnote-pi-lab-bridge-token"; -const PI_LAB_SESSION_TTL_MS: u128 = 30 * 60 * 1000; -const PI_LAB_MAX_SESSIONS: usize = 16; -const PI_LAB_RATE_WINDOW_MS: u128 = 10_000; -const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4; -const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12; -const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40; -const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust"; -const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [ - "read", - "write", - "edit", - "bash", - "grep", - "find", - "ls", - "hashline_edit", -]; -const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json"; -const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000; -const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024; -const PI_LAB_DIAGNOSTICS_MAX_OUTPUT_BYTES: usize = 512 * 1024; -pub const PI_LAB_PROFILE: &str = "pi_lab"; -pub const PI_LAB_ACP_RUNTIME: &str = "pi"; +use super::constants::*; static PI_LAB_SESSIONS: LazyLock>> = LazyLock::new(|| StdMutex::new(HashMap::new())); static PI_LAB_PROCESSES: LazyLock>> = LazyLock::new(|| StdMutex::new(HashMap::new())); +/// 热缓存;权威查询走 control-plane `list_ai_tool_events` / `list_ai_file_patches`。 static PI_LAB_RECEIPT_STORE: LazyLock>> = LazyLock::new(|| StdMutex::new(Vec::new())); static PI_LAB_RATE_LIMITS: LazyLock>>> = @@ -297,6 +259,9 @@ pub struct PiLabSendRequest { pub folder_path: Option, pub context_refs: Option>, pub selected_context: Option, + /// S7: optional agent target package (mnote.agent_target_package.v1) from Page AI host. + #[serde(default)] + pub target_package: Option, } #[derive(Debug, Deserialize)] @@ -787,6 +752,8 @@ fn resolve_file_path( } else { path.to_string() }; + crate::routes::vault_path::deny_if_vault_sensitive_relative_path(&relative_path) + .map_err(|error| error.with_context(context))?; let target = resolve_root_relative_path(state, context, root_uri, &relative_path, require_write)?; return Ok((target, Some(root_uri.to_string()), Some(relative_path))); @@ -800,6 +767,18 @@ fn resolve_file_path( ) .with_context(context)); } + // Absolute-path tools: deny any path whose workspace-relative form hits vault. + let normalized_abs = path.replace('\\', "/"); + if let Some(idx) = normalized_abs.find("/.mnote/vault") { + let tail = &normalized_abs[idx + 1..]; // drop leading '/' + crate::routes::vault_path::deny_if_vault_sensitive_relative_path(tail) + .map_err(|error| error.with_context(context))?; + } else if crate::routes::vault_path::is_vault_sensitive_relative_path(&normalized_abs) { + return Err(crate::routes::vault_path::vault_path_denied_error( + "密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*", + ) + .with_context(context)); + } let allowed = active_allowed_roots(state, context)?; let target = canonical_or_parent(&requested); let allowed_root = allowed @@ -1545,11 +1524,19 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy: | "mnote.knowledge_rag.section_context" | "mnote.knowledge_rag.open_reference" | "mnote.reference.open" + | "mnote.vault.list" + | "mnote.vault.get" | "mnote.tool_receipt.write" => "allow".into(), + // plan: no secret resolve/login _ => "deny".into(), }, Some("auto_edit") => match tool_name { "mnote.local_file.read" | "mnote.local_file.patch" => "allow".into(), + "mnote.vault.list" + | "mnote.vault.get" + | "mnote.vault.resolve" + | "mnote.vault.login" + | "mnote.vault.session" => "allow".into(), "mnote.codex_rescue.request" => "ask".into(), _ => base_policy.into(), }, @@ -1564,12 +1551,52 @@ fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy: "mnote.local_file.read" | "mnote.local_file.patch" | "mnote.codex_rescue.request" => { "ask".into() } + "mnote.vault.list" + | "mnote.vault.get" + | "mnote.vault.resolve" + | "mnote.vault.login" + | "mnote.vault.session" => "allow".into(), + _ => base_policy.into(), + }, + _ => match tool_name { + "mnote.vault.list" + | "mnote.vault.get" + | "mnote.vault.resolve" + | "mnote.vault.login" + | "mnote.vault.session" => "allow".into(), _ => base_policy.into(), }, - _ => base_policy.into(), } } +/// Strip secret values before control-plane receipt persistence. +fn redact_vault_tool_payload(tool_name: &str, payload: &Value) -> Value { + if tool_name != "mnote.vault.resolve" + && tool_name != "mnote.vault.login" + && tool_name != "mnote.vault.session" + { + return payload.clone(); + } + let mut safe = payload.clone(); + if let Some(obj) = safe.as_object_mut() { + if obj.contains_key("value") { + obj.insert("value".into(), Value::String("[redacted]".into())); + } + if obj.contains_key("cookieHeader") { + obj.insert("cookieHeader".into(), Value::String("[redacted]".into())); + } + if let Some(secret) = obj.get_mut("secret") { + if let Some(s) = secret.as_object_mut() { + if s.contains_key("value") { + s.insert("value".into(), Value::String("[redacted]".into())); + } + } + } + obj.insert("receiptRedacted".into(), Value::Bool(true)); + } + safe +} + fn session_permission_mode(session: &PiLabSession) -> Option<&str> { session .runtime_policy_snapshot @@ -2321,6 +2348,36 @@ fn pi_lab_tool_definitions() -> Vec { label: "MNote Codex rescue", description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.", }, + PiLabToolDefinition { + pi_name: "mnote_vault_list", + mnote_name: "mnote.vault.list", + label: "MNote AI vault list", + description: "List credentials in the shared AI password book (L0 metadata only, no secret plaintext). Multi-agent single credential pool. Optional status=active|deleted. Do NOT use generic file tools on .mnote/vault.", + }, + PiLabToolDefinition { + pi_name: "mnote_vault_get", + mnote_name: "mnote.vault.get", + label: "MNote AI vault get", + description: "Get one AI password-book item by id (secrets masked). Params: id. Use mnote.vault.resolve to obtain password/apikey/token plaintext for automation.", + }, + PiLabToolDefinition { + pi_name: "mnote_vault_resolve", + mnote_name: "mnote.vault.resolve", + label: "MNote AI vault resolve", + description: "Resolve a secret field from the shared AI password book with cipher-book expansion. Params: id, field=password|apikey|token. Prefer mnote.vault.login for site login (session reuse). Do not paste value into chat.", + }, + PiLabToolDefinition { + pi_name: "mnote_vault_login", + mnote_name: "mnote.vault.login", + label: "MNote AI vault login", + description: "ONE-SHOT multi-agent login for AI password book. Params: id, optional forceRefresh. Reuses saved session if fresh; otherwise api_first password login and saves session. If Cloudflare/captcha: returns human_required — human uses chrome-bridge/Paseo browser then mnote.vault.session. Prefer this over list+resolve+browser for logins. Do not paste cookieHeader/password into chat.", + }, + PiLabToolDefinition { + pi_name: "mnote_vault_session", + mnote_name: "mnote.vault.session", + label: "MNote AI vault session write-back", + description: "Write browser-captured cookies into AI password book after human Cloudflare/captcha login. Params: id, cookieHeader, optional expiresAt, source=human_bridge|browser. Next mnote.vault.login will reuse them.", + }, PiLabToolDefinition { pi_name: "mnote_tool_receipt_write", mnote_name: "mnote.tool_receipt.write", @@ -3028,6 +3085,96 @@ fn publish_event(session_id: &str, kind: &str, payload: Value) { let _ = PI_LAB_EVENT_TX.send(event); } +fn status_from_persisted(status: &str) -> PiLabSessionStatus { + match status { + "turn_running" => PiLabSessionStatus::Idle, + "runtime_running" => PiLabSessionStatus::Idle, + "aborted" => PiLabSessionStatus::Aborted, + "error" => PiLabSessionStatus::Error, + _ => PiLabSessionStatus::Idle, + } +} + +/// 从 control-plane 恢复会话元数据到热缓存(不自动拉起子进程)。 +fn hydrate_session_from_control_plane( + state: &AppState, + user_id: &str, + session_id: &str, +) -> Result, WebError> { + if session_id.trim().is_empty() || is_pi_lab_warmup_session_id(session_id) { + return Ok(None); + } + if get_session(session_id).is_some() { + return Ok(get_session(session_id)); + } + let run_id = pi_run_id(session_id); + let Some(run) = state + .control_plane() + .find_ai_runtime_run(user_id, &run_id) + .map_err(|e| WebError::internal(format!("hydrate Pi session 失败: {e}")))? + else { + return Ok(None); + }; + if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME { + return Ok(None); + } + if run.user_id != user_id { + return Ok(None); + } + let runtime: Value = serde_json::from_str(&run.runtime_json).unwrap_or(json!({})); + let str_field = |key: &str| -> Option { + runtime + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + }; + let now = now_ms(); + let session = PiLabSession { + session_id: run.session_id.clone(), + mnote_user_id: run.user_id.clone(), + bridge_token: generate_bridge_token(), + status: status_from_persisted(&run.status), + provider_session_id: str_field("providerSessionId") + .unwrap_or_else(|| run.session_id.clone()), + pi_session_dir: str_field("piSessionDir").unwrap_or_default(), + pi_session_file: str_field("piSessionFile"), + root_uri: str_field("rootUri"), + workspace_id: run + .workspace_id + .clone() + .or_else(|| str_field("workspaceId")), + page_path: run.document_id.clone().or_else(|| str_field("pagePath")), + page_title: run.title.clone().or_else(|| str_field("pageTitle")), + model_provider: str_field("modelProvider"), + model_id: str_field("modelId"), + thinking_level: str_field("thinkingLevel"), + allowed_roots_snapshot: runtime.get("allowedRootsSnapshot").cloned(), + runtime_policy_snapshot: runtime.get("runtimePolicy").cloned(), + runtime_pid: None, + runtime_mode: str_field("runtimeMode").unwrap_or_else(|| "real".to_string()), + runtime_error: None, + created_at_ms: now, + updated_at_ms: now, + message_count: runtime + .get("messageCount") + .and_then(Value::as_u64) + .unwrap_or(0), + }; + if session.pi_session_dir.trim().is_empty() { + return Ok(None); + } + upsert_session(session.clone()); + let _ = persist_append_event( + state, + &session, + "session_hydrated", + &json!({"source": "control_plane", "runId": run.run_id}), + ); + Ok(Some(session)) +} + fn upsert_session(session: PiLabSession) { if let Ok(mut sessions) = PI_LAB_SESSIONS.lock() { sessions.insert(session.session_id.clone(), session); @@ -3219,11 +3366,25 @@ fn get_session_for_context( context: &RequestContext, session_id: &str, ) -> Result { - let session = get_session(session_id).ok_or_else(|| { - WebError::bad_request_code("page_ai_pi_lab_session_not_found", "Pi Lab session 不存在") - })?; - ensure_session_owner(state, context, &session)?; - Ok(session) + if let Some(session) = get_session(session_id) { + ensure_session_owner(state, context, &session)?; + return Ok(session); + } + // S6: process restart / cold cache — hydrate metadata from control-plane + // so send/events/status can resume the same sessionId without a full start. + let actor_id = context.auth.actor_id.trim(); + if !actor_id.is_empty() && actor_id != "anonymous" { + if let Ok(Some(session)) = + hydrate_session_from_control_plane(state, actor_id, session_id) + { + ensure_session_owner(state, context, &session)?; + return Ok(session); + } + } + Err(WebError::bad_request_code( + "page_ai_pi_lab_session_not_found", + "Pi Lab session 不存在", + )) } fn bridge_token_from_headers(headers: &HeaderMap) -> Option<&str> { @@ -4668,12 +4829,153 @@ impl PiLabToolFacade { "mnote.knowledge_rag.section_context", "mnote.knowledge_rag.open_reference", "mnote.reference.open", + "mnote.vault.list", + "mnote.vault.get", + "mnote.vault.resolve", + "mnote.vault.login", + "mnote.vault.session", "mnote.codex_rescue.request", "mnote.tool_receipt.write" ], })) } + fn vault_list(&self, params: Value) -> Result { + let status = params + .get("status") + .and_then(Value::as_str) + .and_then(crate::routes::vault_store::VaultItemStatus::parse) + .unwrap_or(crate::routes::vault_store::VaultItemStatus::Active); + crate::routes::vault::list_ai_vault_items(status) + } + + fn vault_get(&self, params: Value) -> Result { + let id = string_param(¶ms, "id") + .or_else(|| string_param(¶ms, "credentialId")) + .ok_or_else(|| { + WebError::bad_request_code("page_ai_vault_id_required", "mnote.vault.get 需要 id") + })?; + crate::routes::vault::get_ai_vault_item(&id) + } + + fn vault_resolve(&self, params: Value) -> Result { + let id = string_param(¶ms, "id") + .or_else(|| string_param(¶ms, "credentialId")) + .ok_or_else(|| { + WebError::bad_request_code( + "page_ai_vault_id_required", + "mnote.vault.resolve 需要 id", + ) + })?; + let field = string_param(¶ms, "field").ok_or_else(|| { + WebError::bad_request_code( + "page_ai_vault_field_required", + "mnote.vault.resolve 需要 field=password|apikey|token", + ) + })?; + let actor = { + let id = self.context.auth.actor_id.trim(); + if id.is_empty() { + "anonymous".to_string() + } else { + id.to_string() + } + }; + if actor == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "vault_auth_required", + "密码箱 resolve 需要登录会话", + )); + } + crate::routes::vault::resolve_ai_vault_secret( + &id, + &field, + &actor, + Some(self.context.trace.request_id.as_str()), + ) + } + + fn vault_login(&self, params: Value) -> Result { + let id = string_param(¶ms, "id") + .or_else(|| string_param(¶ms, "credentialId")) + .ok_or_else(|| { + WebError::bad_request_code( + "page_ai_vault_id_required", + "mnote.vault.login 需要 id", + ) + })?; + let force = bool_param(¶ms, "forceRefresh") + .or_else(|| bool_param(¶ms, "force_refresh")) + .unwrap_or(false); + let actor = { + let id = self.context.auth.actor_id.trim(); + if id.is_empty() { + "anonymous".to_string() + } else { + id.to_string() + } + }; + if actor == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "vault_auth_required", + "密码箱 login 需要登录会话", + )); + } + crate::routes::vault::login_ai_vault_credential( + &id, + force, + &actor, + Some(self.context.trace.request_id.as_str()), + ) + } + + fn vault_session(&self, params: Value) -> Result { + let id = string_param(¶ms, "id") + .or_else(|| string_param(¶ms, "credentialId")) + .ok_or_else(|| { + WebError::bad_request_code( + "page_ai_vault_id_required", + "mnote.vault.session 需要 id", + ) + })?; + let cookie = string_param(¶ms, "cookieHeader") + .or_else(|| string_param(¶ms, "cookie_header")) + .ok_or_else(|| { + WebError::bad_request_code( + "page_ai_vault_cookie_required", + "mnote.vault.session 需要 cookieHeader", + ) + })?; + let expires = string_param(¶ms, "expiresAt") + .or_else(|| string_param(¶ms, "expires_at")); + let source = string_param(¶ms, "source").unwrap_or_else(|| "human_bridge".into()); + let actor = { + let id = self.context.auth.actor_id.trim(); + if id.is_empty() { + "anonymous".to_string() + } else { + id.to_string() + } + }; + if actor == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "vault_auth_required", + "密码箱 session 需要登录会话", + )); + } + crate::routes::vault::put_ai_vault_session( + &id, + &cookie, + expires.as_deref(), + &source, + &actor, + Some(self.context.trace.request_id.as_str()), + ) + } + fn local_file_read(&self, params: Value) -> Result { let (target, root_uri, relative_path) = resolve_file_path( &self.state, @@ -5205,6 +5507,11 @@ async fn execute_tool( } "mnote.knowledge_rag.open_reference" => facade.reference_open(params.clone()).await, "mnote.reference.open" => facade.reference_open(params.clone()).await, + "mnote.vault.list" => facade.vault_list(params.clone()), + "mnote.vault.get" => facade.vault_get(params.clone()), + "mnote.vault.resolve" => facade.vault_resolve(params.clone()), + "mnote.vault.login" => facade.vault_login(params.clone()), + "mnote.vault.session" => facade.vault_session(params.clone()), "mnote.codex_rescue.request" => facade.codex_rescue_request(params.clone()).await, "mnote.tool_receipt.write" => Ok(json!({ "requestedReceipt": params, @@ -5286,7 +5593,14 @@ async fn execute_tool( before_file_version.clone(), after_file_version.clone(), ); - let receipt_payload = write_receipt(&facade.state, receipt, &payload, citation_count); + // Never persist vault secret values into control-plane receipts / journals. + let receipt_safe_payload = redact_vault_tool_payload(&tool_name, &payload); + let receipt_payload = write_receipt( + &facade.state, + receipt, + &receipt_safe_payload, + citation_count, + ); let elapsed_ms = now_ms().saturating_sub(started) as u64; if let Some(session) = session.as_ref() { let tool_event_id = receipt_payload @@ -5467,6 +5781,17 @@ pub async fn status( let runtime_error = current_session .as_ref() .and_then(|session| session.runtime_error.clone()); + // S1: status defaults must match effective AI policy (management surface truth), + // not only env hardcodes. Frontend checkStatus previously overwrote effective + // defaults with these fields. + let effective_policy = + ai_settings::load_effective_ai_runtime_policy(&state, &actor_id, None); + let (status_default_provider, status_default_model_id) = match effective_policy + .resolve_requested_model(None, None) + { + Ok(resolved) => (resolved.provider, resolved.model_id), + Err(_) => (default_model_provider(), default_model_id()), + }; Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_STATUS, @@ -5480,8 +5805,9 @@ pub async fn status( "runtimeAvailable": runtime_available, "runtimeInstallHint": runtime_install_hint, "runtimeError": runtime_error, - "defaultModelProvider": default_model_provider(), - "defaultModelId": default_model_id(), + "defaultModelProvider": status_default_provider, + "defaultModelId": status_default_model_id, + "defaultModel": format!("{status_default_provider}/{status_default_model_id}"), "defaultThinkingLevel": default_thinking_level(), "permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)), "advancedRuntime": current_session.as_ref().map(pi_lab_effective_advanced_runtime_config).unwrap_or_else(PiLabAdvancedRuntimeConfig::empty), @@ -5590,6 +5916,7 @@ pub async fn bootstrap( folder_path: None, context_refs: None, selected_context: None, + target_package: None, }), ) .await? @@ -5614,6 +5941,9 @@ pub async fn start( let mut requested_session = requested_session; requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?; if let Some(existing_session_id) = request.session_id.as_deref() { + if get_session(existing_session_id).is_none() { + let _ = hydrate_session_from_control_plane(&state, &actor_id, existing_session_id)?; + } if let Some(mut existing_session) = get_session(existing_session_id) { ensure_session_owner(&state, &context, &existing_session)?; if session_runtime_is_usable(&existing_session) @@ -5888,6 +6218,8 @@ pub async fn send( "folderPath": request.folder_path, "contextRefs": request.context_refs, "selectedContext": request.selected_context, + // S7: host-built agent target package (optional; client also enforces dirty gate). + "targetPackage": request.target_package, }, }); if let Some(images) = request.images.clone().filter(|images| !images.is_empty()) { @@ -9463,7 +9795,7 @@ mod tests { #[test] fn pi_rust_start_keeps_official_prompt_templates_and_context_files_enabled() { - let source = include_str!("page_ai_pi.rs"); + let source = include_str!("runtime.rs"); assert!(!source.contains(".arg(\"--no-prompt-templates\")")); assert!(!source.contains(".arg(\"--no-context-files\")")); } @@ -9836,6 +10168,129 @@ mod tests { assert_eq!(denied_payload["code"], "page_ai_pi_lab_model_not_allowed"); } + /// S1: admin/user AI policy defaultModel must apply on the next Pi start when + /// the client does not pass modelProvider/modelId (management surface is truth). + #[tokio::test] + async fn start_without_model_uses_effective_default_and_picks_up_policy_change() { + std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock"); + let state = test_state(); + let actor_id = "pi_default_model_s1_user"; + let root = temp_root("mnote-pi-default-model-s1-root"); + let root_uri = grant_directory(&state, actor_id, &root, "write"); + upsert_ai_policy( + &state, + actor_id, + json!({ + "defaultModel": "omniroute/pi-fast", + "allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"] + }), + ); + let app = build_app(state.clone()); + + let (status_a, payload_a) = request_json( + app.clone(), + "/api/page-ai/pi/start", + actor_id, + json!({ + "sessionId": "pi_lab_s1_default_a", + "rootUri": root_uri, + }), + ) + .await; + assert_eq!(status_a, StatusCode::OK, "start A: {payload_a}"); + assert_eq!(payload_a["session"]["modelProvider"], "omniroute"); + assert_eq!(payload_a["session"]["modelId"], "pi-fast"); + assert_eq!( + payload_a["session"]["runtimePolicySnapshot"]["defaultModel"], + "omniroute/pi-fast" + ); + + // Simulate management-surface defaultModel change (same control-plane policy). + upsert_ai_policy( + &state, + actor_id, + json!({ + "defaultModel": "omniroute/pi-reason", + "allowedModels": ["omniroute/pi-fast", "omniroute/pi-reason"] + }), + ); + + let (status_b, payload_b) = request_json( + app, + "/api/page-ai/pi/start", + actor_id, + json!({ + "sessionId": "pi_lab_s1_default_b", + "rootUri": root_uri, + }), + ) + .await; + assert_eq!(status_b, StatusCode::OK, "start B: {payload_b}"); + assert_eq!(payload_b["session"]["modelProvider"], "omniroute"); + assert_eq!( + payload_b["session"]["modelId"], + "pi-reason", + "next start without explicit model must pick up new defaultModel" + ); + assert_eq!( + payload_b["session"]["runtimePolicySnapshot"]["defaultModel"], + "omniroute/pi-reason" + ); + } + + /// S1: /status defaultModel* must mirror effective policy, not only env hardcodes. + #[tokio::test] + async fn status_default_model_matches_effective_ai_policy() { + std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock"); + // Env hardcode must not win over policy when policy is present. + std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL", "env-stale-model"); + std::env::set_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER", "omniroute"); + let state = test_state(); + let actor_id = "pi_status_default_s1_user"; + // Policy FK requires the user row first (grant_directory also upserts user). + let _root = grant_directory( + &state, + actor_id, + &temp_root("mnote-pi-status-default-s1-root"), + "read", + ); + upsert_ai_policy( + &state, + actor_id, + json!({ + "defaultModel": "omniroute/pi-reason", + "allowedModels": ["omniroute/pi-reason", "omniroute/pi-fast"] + }), + ); + let app = build_app(state); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/page-ai/pi/status") + .header("x-mnote-actor-id", actor_id) + .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 payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["ok"], true); + assert_eq!(payload["defaultModelProvider"], "omniroute"); + assert_eq!( + payload["defaultModelId"], "pi-reason", + "status must not report env-stale default when policy exists: {payload}" + ); + assert_eq!(payload["defaultModel"], "omniroute/pi-reason"); + std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL"); + std::env::remove_var("MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER"); + } + #[test] fn hydrate_session_mcp_cache_copies_shared_cache() { let root = temp_root("mnote-pi-shared-mcp-cache-root"); diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 910a8e6a..0d50e066 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -421,6 +421,41 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet { .unwrap_or_default() } +/// Map a page-tree row's storage relative path to the directory scope used for lazy expand. +/// - Nested bundle `Folder/Folder.md` → `Folder` +/// - Sibling page `Folder.md` (+ `Folder/`) → `Folder` +/// - Page-group / directory row → itself +pub(crate) fn page_tree_expand_relative_path(relative_path: &str) -> String { + let rp = relative_path + .trim() + .trim_matches('/') + .replace('\\', "/"); + if rp.is_empty() { + return String::new(); + } + let lower = rp.to_ascii_lowercase(); + if !lower.ends_with(".md") { + return rp; + } + let stem = &rp[..rp.len().saturating_sub(3)]; + if let Some((parent, file)) = rp.rsplit_once('/') { + let file_stem = file + .strip_suffix(".md") + .or_else(|| file.strip_suffix(".MD")) + .or_else(|| file.strip_suffix(".Md")) + .unwrap_or(file); + let parent_name = parent.rsplit_once('/').map(|(_, name)| name).unwrap_or(parent); + if parent_name == file_stem { + // Nested bundle: markdown lives inside a same-named folder. + return parent.to_string(); + } + // Sibling page under a parent directory. + return stem.to_string(); + } + // Root-level sibling page. + stem.to_string() +} + pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec { let mut rows: Vec = projection .get("items") @@ -441,6 +476,47 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec 0) + .unwrap_or(false) + }); + let relative_path = item + .get("expandRelativePath") + .or_else(|| item.get("relativePath")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + item.get("resourceMeta") + .and_then(|meta| meta.get("workspacePath")) + .and_then(|path| path.get("relativePath")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) + .or_else(|| { + item.get("resourceMeta") + .and_then(|meta| meta.pointer("/extra/source/relativePath")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }); + let expand_relative_path = if expandable { + relative_path + .as_deref() + .map(page_tree_expand_relative_path) + .filter(|value| !value.is_empty()) + } else { + None + }; Some(PageTreeRenderRow { node_id: node_id.to_string(), parent_node_id: item @@ -458,15 +534,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec 0) - .unwrap_or(false) - }), + expandable, expanded: item .get("expandedByDefault") .and_then(Value::as_bool) @@ -480,6 +548,7 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec bool { + std::env::var("MNOTE_VAULT") + .ok() + .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(true) +} + +pub fn vault_disabled_error() -> WebError { + WebError::new( + StatusCode::NOT_FOUND, + "vault_disabled", + "密码箱未启用(设置 MNOTE_VAULT=1 后重试)", + ) +} + +fn require_vault_enabled() -> Result<(), WebError> { + if vault_feature_enabled() { + Ok(()) + } else { + Err(vault_disabled_error()) + } +} + +fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json) { + let mut headers = HeaderMap::new(); + stamp_headers(&mut headers); + ( + StatusCode::OK, + headers, + Json(json!({ + "ok": true, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "owner": "mnote-web", + "result": result, + })), + ) +} + +fn stamp_headers(headers: &mut HeaderMap) { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(HEADER_VAULT_TRANSPORT.as_bytes()) { + headers.insert(name, HeaderValue::from_static("vault-api")); + } +} + +fn actor_id(context: &RequestContext) -> String { + let id = context.auth.actor_id.trim(); + if id.is_empty() { + "anonymous".into() + } else { + id.to_string() + } +} + +fn require_authenticated(context: &RequestContext) -> Result { + let id = actor_id(context); + if id == "anonymous" { + return Err(WebError::new( + StatusCode::UNAUTHORIZED, + "vault_auth_required", + "密码箱需要登录会话", + )); + } + Ok(id) +} + +fn require_root_uri(root_uri: Option<&str>) -> Result<&str, WebError> { + root_uri + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("vault_root_required", "缺少本地文件夹 rootUri") + }) +} + +fn workspace_root_from_uri(root_uri: &str) -> Result { + // ensure_* already validated access; re-parse for store ops. + let trimmed = root_uri.trim(); + let Some(path) = trimmed.strip_prefix("file://") else { + return Err(WebError::bad_request_code( + "vault_root_invalid", + "rootUri 必须使用 file://", + )); + }; + if path.trim().is_empty() { + return Err(WebError::bad_request_code( + "vault_root_invalid", + "rootUri 不能为空", + )); + } + // Percent-decode minimal: reuse local_folder via ensure which returns PathBuf. + // Callers always run ensure_* first which validates the URI. + Ok(PathBuf::from(percent_decode_path(path)?)) +} + +fn percent_decode_path(value: &str) -> Result { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() { + return Err(WebError::bad_request_code( + "vault_root_invalid", + "file:// rootUri 包含不完整的百分号编码", + )); + } + let hex = &value[index + 1..index + 3]; + let byte = u8::from_str_radix(hex, 16).map_err(|_| { + WebError::bad_request_code( + "vault_root_invalid", + "file:// rootUri 包含无效的百分号编码", + ) + })?; + decoded.push(byte); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).map_err(|_| { + WebError::bad_request_code("vault_root_invalid", "file:// rootUri 不是合法 UTF-8 路径") + }) +} + +fn parse_status(raw: Option<&str>) -> Result { + match raw.map(str::trim).filter(|v| !v.is_empty()) { + None | Some("active") => Ok(VaultItemStatus::Active), + Some("deleted") => Ok(VaultItemStatus::Deleted), + Some(other) => Err(WebError::bad_request_code( + "vault_status_invalid", + format!("status 仅支持 active|deleted,收到: {other}"), + )), + } +} + +fn opt_string(value: Option<&Value>) -> Option { + match value { + None | Some(Value::Null) => None, + Some(Value::String(s)) => { + let t = s.trim(); + if t.is_empty() { + None + } else { + Some(s.clone()) + } + } + Some(other) => other.as_str().map(|s| s.to_string()), + } +} + +fn parse_tags(value: Option<&Value>) -> Vec { + match value { + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| item.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()) + .collect(), + Some(Value::String(s)) => s + .split(|c| c == ',' || c == ';' || c == '\n') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect(), + _ => Vec::new(), + } +} + +fn parse_fields(value: Option<&Value>) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(Value::Object(map)) = value else { + return out; + }; + for (key, val) in map { + if let Some(s) = val.as_str() { + out.insert(key.clone(), s.to_string()); + } + } + out +} + +fn parse_secret_create(value: Option<&Value>) -> Result, WebError> { + match value { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => { + if vault_store::is_mask_sentinel(s) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入密钥字段", + )); + } + if s.is_empty() { + Ok(None) + } else { + Ok(Some(s.clone())) + } + } + Some(Value::Object(obj)) => { + if obj.get("clear").and_then(Value::as_bool) == Some(true) { + return Ok(None); + } + if let Some(set) = obj.get("set") { + return parse_secret_create(Some(set)); + } + Ok(None) + } + Some(_) => Err(WebError::bad_request_code( + "vault_secret_invalid", + "密钥字段格式无效", + )), + } +} + +fn parse_secret_patch(body: &Map, key: &str) -> Result { + if !body.contains_key(key) { + return Ok(SecretPatch::Omit); + } + match body.get(key) { + None | Some(Value::Null) => Ok(SecretPatch::Clear), + Some(Value::String(s)) => { + if vault_store::is_mask_sentinel(s) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入密钥字段", + )); + } + if s.is_empty() { + Ok(SecretPatch::Clear) + } else { + Ok(SecretPatch::Set(s.clone())) + } + } + Some(Value::Object(obj)) => { + if obj.get("clear").and_then(Value::as_bool) == Some(true) { + return Ok(SecretPatch::Clear); + } + if let Some(set) = obj.get("set") { + match set { + Value::Null => Ok(SecretPatch::Clear), + Value::String(s) => { + if vault_store::is_mask_sentinel(s) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入密钥字段", + )); + } + if s.is_empty() { + Ok(SecretPatch::Clear) + } else { + Ok(SecretPatch::Set(s.clone())) + } + } + _ => Err(WebError::bad_request_code( + "vault_secret_invalid", + "密钥 set 必须是字符串", + )), + } + } else { + Ok(SecretPatch::Omit) + } + } + Some(_) => Err(WebError::bad_request_code( + "vault_secret_invalid", + "密钥字段格式无效", + )), + } +} + +fn parse_optional_nullable_string( + body: &Map, + key: &str, +) -> Option> { + if !body.contains_key(key) { + return None; + } + match body.get(key) { + None | Some(Value::Null) => Some(None), + Some(Value::String(s)) => { + let t = s.trim(); + if t.is_empty() { + Some(None) + } else { + Some(Some(s.clone())) + } + } + Some(other) => other.as_str().map(|s| Some(s.to_string())), + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultRootQuery { + /// Reserved for multi-source workspaces; resolve uses `root_uri` only in P0. + #[allow(dead_code)] + pub source_kind: Option, + pub root_uri: Option, + pub status: Option, + /// Optional deep-link entry id from query string. + #[allow(dead_code)] + pub id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultRootBody { + /// Reserved for multi-source workspaces; resolve uses `root_uri` only in P0. + #[allow(dead_code)] + pub source_kind: Option, + pub root_uri: Option, +} + +async fn resolve_write_root( + state: &AppState, + context: &RequestContext, + root_uri: &str, +) -> Result { + let path = ensure_local_workspace_write_access_with_state(state, context, root_uri) + .map_err(|error| error.with_context(context))?; + Ok(path) +} + +async fn resolve_read_root( + state: &AppState, + context: &RequestContext, + root_uri: &str, +) -> Result { + let path = ensure_local_workspace_read_access_with_state(state, context, root_uri) + .map_err(|error| error.with_context(context))?; + Ok(path) +} + +/// POST /api/vault/ensure +pub async fn ensure( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + require_authenticated(&context)?; + let root_uri = require_root_uri(body.root_uri.as_deref())?; + let root = resolve_write_root(&state, &context, root_uri).await?; + let vault = vault_store::ensure_vault_directories(&root)?; + Ok(ok_response( + &context, + json!({ + "schema": "mnote.vault.ensure.v1", + "vaultRoot": vault.display().to_string(), + "relativePath": ".mnote/vault", + }), + )) +} + +/// POST /api/vault/reindex +pub async fn reindex( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + require_authenticated(&context)?; + let root_uri = require_root_uri(body.root_uri.as_deref())?; + let root = resolve_write_root(&state, &context, root_uri).await?; + let index = vault_store::reindex(&root)?; + Ok(ok_response( + &context, + json!({ + "schema": "mnote.vault.index.v1", + "revision": index.revision, + "updatedAt": index.updated_at, + "entryCount": index.entries.len(), + }), + )) +} + +fn vault_role_meta(root: &std::path::Path) -> (bool, String) { + let ai_actor = ai_vault_actor_id(); + let ai_root = local_folder_source::managed_default_workspace_root_for_actor(&ai_actor); + let is_ai = match (root.canonicalize(), ai_root.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => root == ai_root, + }; + (is_ai, ai_actor) +} + +/// GET /api/vault/list +pub async fn list( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + require_authenticated(&context)?; + let root_uri = require_root_uri(query.root_uri.as_deref())?; + let root = resolve_read_root(&state, &context, root_uri).await?; + let status = parse_status(query.status.as_deref())?; + let (index, items) = vault_store::list_credentials(&root, status)?; + let projected: Vec = items + .iter() + .map(vault_store::project_list_entry) + .collect(); + let (is_ai_vault, ai_actor) = vault_role_meta(&root); + Ok(ok_response( + &context, + json!({ + "schema": "mnote.vault.list.v1", + "status": status.as_str(), + "revision": index.revision, + "updatedAt": index.updated_at, + "items": projected, + "isAiVault": is_ai_vault, + "aiVaultActorId": ai_actor, + "vaultRole": if is_ai_vault { "ai" } else { "user" }, + }), + )) +} + +/// GET /api/vault/items/{id} +pub async fn get_item( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + require_authenticated(&context)?; + let root_uri = require_root_uri(query.root_uri.as_deref())?; + let root = resolve_read_root(&state, &context, root_uri).await?; + let record = vault_store::get_credential(&root, &id)?; + Ok(ok_response( + &context, + json!({ + "item": vault_store::project_item_l0(&record), + }), + )) +} + +/// POST /api/vault/items +pub async fn create_item( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let root_uri = require_root_uri( + map.get("rootUri") + .and_then(Value::as_str) + .or_else(|| map.get("root_uri").and_then(Value::as_str)), + )?; + let root = resolve_write_root(&state, &context, root_uri).await?; + let title = map + .get("title") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let input = VaultCreateInput { + title, + url: opt_string(map.get("url")), + username: opt_string(map.get("username")), + password: parse_secret_create(map.get("password"))?, + password_hint: opt_string(map.get("passwordHint").or_else(|| map.get("password_hint"))), + email: opt_string(map.get("email")), + apikey: parse_secret_create(map.get("apikey").or_else(|| map.get("apiKey")))?, + token: parse_secret_create(map.get("token"))?, + folder_path: opt_string(map.get("folderPath").or_else(|| map.get("folder_path"))), + tags: parse_tags(map.get("tags")), + fields: parse_fields(map.get("fields")), + notes_markdown: map + .get("notesMarkdown") + .or_else(|| map.get("notes_markdown")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + }; + let record = vault_store::create_credential(&root, input)?; + let _ = vault_store::append_vault_audit( + &root, + "create", + &actor, + &record.id, + None, + Some(context.trace.request_id.as_str()), + true, + ); + Ok(ok_response( + &context, + json!({ + "item": vault_store::project_item_l0(&record), + }), + )) +} + +/// PATCH /api/vault/items/{id} +pub async fn update_item( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let root_uri = require_root_uri( + map.get("rootUri") + .and_then(Value::as_str) + .or_else(|| map.get("root_uri").and_then(Value::as_str)), + )?; + let root = resolve_write_root(&state, &context, root_uri).await?; + let base_revision = map + .get("baseRevision") + .or_else(|| map.get("base_revision")) + .and_then(Value::as_u64); + let input = VaultUpdateInput { + title: map + .get("title") + .and_then(Value::as_str) + .map(|s| s.to_string()), + url: parse_optional_nullable_string(map, "url"), + username: parse_optional_nullable_string(map, "username"), + email: parse_optional_nullable_string(map, "email"), + password_hint: parse_optional_nullable_string(map, "passwordHint") + .or_else(|| parse_optional_nullable_string(map, "password_hint")), + folder_path: parse_optional_nullable_string(map, "folderPath") + .or_else(|| parse_optional_nullable_string(map, "folder_path")), + tags: if map.contains_key("tags") { + Some(parse_tags(map.get("tags"))) + } else { + None + }, + fields: if map.contains_key("fields") { + Some(parse_fields(map.get("fields"))) + } else { + None + }, + notes_markdown: map + .get("notesMarkdown") + .or_else(|| map.get("notes_markdown")) + .and_then(Value::as_str) + .map(|s| s.to_string()), + password: parse_secret_patch(map, "password")?, + apikey: if map.contains_key("apikey") { + parse_secret_patch(map, "apikey")? + } else if map.contains_key("apiKey") { + parse_secret_patch(map, "apiKey")? + } else { + SecretPatch::Omit + }, + token: parse_secret_patch(map, "token")?, + base_revision, + }; + let record = vault_store::update_credential(&root, &id, input)?; + let _ = vault_store::append_vault_audit( + &root, + "update", + &actor, + &record.id, + None, + Some(context.trace.request_id.as_str()), + true, + ); + + // Auto-push linked AI password-book copy when present. + let sync_ai = map + .get("syncAiShare") + .or_else(|| map.get("sync_ai_share")) + .and_then(Value::as_bool) + .unwrap_or(true); + let mut ai_sync_json = Value::Null; + let mut record_out = record; + if sync_ai { + if let Some(link) = record_out.shared_to_ai.clone() { + let target_actor = if link.target_actor_id.trim().is_empty() { + ai_vault_actor_id() + } else { + link.target_actor_id.clone() + }; + let target_root = + local_folder_source::managed_default_workspace_root_for_actor(&target_actor); + if target_root.exists() { + if let Ok(target_root) = target_root.canonicalize() { + if target_root != root { + match vault_store::maybe_sync_after_source_update( + &root, + &record_out, + &target_root, + &actor, + true, + ) { + Ok(Some(outcome)) => { + record_out = outcome.source; + ai_sync_json = json!({ + "ok": true, + "updatedExisting": outcome.updated_existing, + "targetId": outcome.item.id, + "targetActorId": target_actor, + "cipherSync": { + "copiedKeys": outcome.cipher_keys_copied, + "skippedExistingKeys": outcome.cipher_keys_skipped_existing, + "missingInSource": outcome.cipher_keys_missing_in_source, + }, + }); + let _ = vault_store::append_vault_audit( + &root, + "ai_share_auto_sync", + &actor, + &id, + Some(outcome.item.id.as_str()), + Some(context.trace.request_id.as_str()), + true, + ); + } + Ok(None) => { + ai_sync_json = json!({ + "ok": false, + "code": "vault_ai_share_target_missing", + "message": "已标记共享但 AI 副本不存在,请重新「共享到 AI 密码本」", + }); + } + Err(err) => { + ai_sync_json = json!({ + "ok": false, + "code": err.code(), + "message": err.message(), + }); + } + } + } + } + } + } + } + + Ok(ok_response( + &context, + json!({ + "item": vault_store::project_item_l0(&record_out), + "aiShareSync": ai_sync_json, + }), + )) +} + +/// DELETE /api/vault/items/{id} +pub async fn delete_item( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let root_uri = require_root_uri(query.root_uri.as_deref())?; + let root = resolve_write_root(&state, &context, root_uri).await?; + let record = vault_store::soft_delete_credential(&root, &id)?; + let _ = vault_store::append_vault_audit( + &root, + "soft_delete", + &actor, + &record.id, + None, + Some(context.trace.request_id.as_str()), + true, + ); + Ok(ok_response( + &context, + json!({ + "item": vault_store::project_item_l0(&record), + }), + )) +} + +/// POST /api/vault/items/{id}/restore +pub async fn restore_item( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let root_uri = require_root_uri(body.root_uri.as_deref())?; + let root = resolve_write_root(&state, &context, root_uri).await?; + let record = vault_store::restore_credential(&root, &id)?; + let _ = vault_store::append_vault_audit( + &root, + "restore", + &actor, + &record.id, + None, + Some(context.trace.request_id.as_str()), + true, + ); + Ok(ok_response( + &context, + json!({ + "item": vault_store::project_item_l0(&record), + }), + )) +} + +/// POST /api/vault/items/{id}/purge +pub async fn purge_item( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let root_uri = require_root_uri(body.root_uri.as_deref())?; + let root = resolve_write_root(&state, &context, root_uri).await?; + vault_store::purge_credential(&root, &id)?; + let _ = vault_store::append_vault_audit( + &root, + "purge", + &actor, + &id, + None, + Some(context.trace.request_id.as_str()), + true, + ); + Ok(ok_response( + &context, + json!({ + "purged": true, + "id": id, + }), + )) +} + +/// POST /api/vault/items/{id}/reveal +pub async fn reveal_item( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let root_uri = require_root_uri( + map.get("rootUri") + .and_then(Value::as_str) + .or_else(|| map.get("root_uri").and_then(Value::as_str)), + )?; + let field = map + .get("field") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "vault_reveal_field_required", + "reveal 需要 field: password|apikey|token", + ) + })?; + let field_norm = match field { + "password" | "apikey" | "apiKey" | "token" => { + if field == "apiKey" { + "apikey" + } else { + field + } + } + other => { + return Err(WebError::bad_request_code( + "vault_reveal_field_invalid", + format!("不支持 reveal 字段: {other}"), + )); + } + }; + // design: reveal requires authenticated session (already) + read access to workspace. + // Write grant not required for reveal of own vault. + let root = resolve_read_root(&state, &context, root_uri).await?; + let record = match vault_store::get_credential(&root, &id) { + Ok(r) => r, + Err(err) => { + let _ = vault_store::append_vault_audit( + &root, + "reveal", + &actor, + &id, + Some(field_norm), + Some(context.trace.request_id.as_str()), + false, + ); + return Err(err); + } + }; + let secret = match field_norm { + "password" => record.password.as_deref(), + "apikey" => record.apikey.as_deref(), + "token" => record.token.as_deref(), + _ => None, + }; + let secret_proj = match vault_store::project_secret_revealed(&root, secret) { + Ok(v) => v, + Err(err) => { + let _ = vault_store::append_vault_audit( + &root, + "reveal", + &actor, + &id, + Some(field_norm), + Some(context.trace.request_id.as_str()), + false, + ); + return Err(err); + } + }; + let _ = vault_store::append_vault_audit( + &root, + "reveal", + &actor, + &id, + Some(field_norm), + Some(context.trace.request_id.as_str()), + true, + ); + Ok(ok_response( + &context, + json!({ + "schema": "mnote.vault.secret.v1", + "id": id, + "field": field_norm, + "secret": secret_proj, + }), + )) +} + +/// Default AI password-book actor (local managed workspace owner). +/// Override with `MNOTE_AI_VAULT_ACTOR` env. +pub fn ai_vault_actor_id() -> String { + std::env::var("MNOTE_AI_VAULT_ACTOR") + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "mnote-e2e".to_string()) +} + +/// Managed default workspace root for the AI password-book actor. +pub fn ai_vault_workspace_root() -> PathBuf { + local_folder_source::managed_default_workspace_root_for_actor(&ai_vault_actor_id()) +} + +/// Whether `root` is the AI password-book workspace (canonical compare when possible). +pub fn is_ai_vault_workspace(root: &std::path::Path) -> bool { + let ai = ai_vault_workspace_root(); + match (root.canonicalize(), ai.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => root == ai, + } +} + +/// Ensure AI password-book workspace + vault dirs exist; return absolute root. +pub fn ensure_ai_vault_workspace() -> Result { + let actor = ai_vault_actor_id(); + let root = ai_vault_workspace_root(); + if !root.exists() { + let _ = local_folder_source::create_default_local_workspace_for_actor(&actor, "user")?; + } + let root = root.canonicalize().map_err(|e| { + WebError::bad_request_code( + "vault_ai_root_unavailable", + format!("无法访问 AI 密码本工作区: {e}"), + ) + })?; + let _ = vault_store::ensure_vault_directories(&root)?; + Ok(root) +} + +fn normalize_secret_field(field: &str) -> Result<&'static str, WebError> { + match field.trim() { + "password" => Ok("password"), + "apikey" | "apiKey" | "api_key" => Ok("apikey"), + "token" => Ok("token"), + other => Err(WebError::bad_request_code( + "vault_resolve_field_invalid", + format!("不支持字段: {other};仅 password|apikey|token"), + )), + } +} + +/// Core resolve for multi-agent: AI password-book only, cipher expand, audit without value. +pub fn resolve_ai_vault_secret( + credential_id: &str, + field: &str, + actor: &str, + request_id: Option<&str>, +) -> Result { + require_vault_enabled()?; + let field_norm = normalize_secret_field(field)?; + let root = ensure_ai_vault_workspace()?; + let record = match vault_store::get_credential(&root, credential_id) { + Ok(r) => r, + Err(err) => { + let _ = vault_store::append_vault_audit( + &root, + "resolve", + actor, + credential_id, + Some(field_norm), + request_id, + false, + ); + return Err(err); + } + }; + if record.status != VaultItemStatus::Active { + let _ = vault_store::append_vault_audit( + &root, + "resolve", + actor, + credential_id, + Some(field_norm), + request_id, + false, + ); + return Err(WebError::bad_request_code( + "vault_resolve_inactive", + "只能 resolve 在用条目", + )); + } + let secret = match field_norm { + "password" => record.password.as_deref(), + "apikey" => record.apikey.as_deref(), + "token" => record.token.as_deref(), + _ => None, + }; + let secret_proj = match vault_store::project_secret_revealed(&root, secret) { + Ok(v) => v, + Err(err) => { + let _ = vault_store::append_vault_audit( + &root, + "resolve", + actor, + credential_id, + Some(field_norm), + request_id, + false, + ); + return Err(err); + } + }; + let _ = vault_store::append_vault_audit( + &root, + "resolve", + actor, + credential_id, + Some(field_norm), + request_id, + true, + ); + let value = secret_proj + .get("value") + .and_then(Value::as_str) + .unwrap_or(""); + let state = secret_proj + .get("state") + .and_then(Value::as_str) + .unwrap_or("absent"); + Ok(json!({ + "schema": "mnote.vault.resolve.v1", + "id": credential_id, + "field": field_norm, + "resolved": state == "revealed", + "state": state, + "value": if state == "revealed" { Value::String(value.to_string()) } else { Value::Null }, + "usedCipherKeys": secret_proj.get("usedCipherKeys").cloned().unwrap_or(json!([])), + "missingCipherKeys": secret_proj.get("missingCipherKeys").cloned().unwrap_or(json!([])), + "template": secret_proj.get("template").cloned().unwrap_or(Value::Null), + "aiVaultActorId": ai_vault_actor_id(), + "transcriptHint": format!("已解析 {field_norm}"), + "note": "多 agent 唯一读密通道;勿把 value 写入聊天/commit/RAG", + })) +} + +/// List AI password-book L0 entries (multi-agent shared pool). +pub fn list_ai_vault_items(status: VaultItemStatus) -> Result { + require_vault_enabled()?; + let root = ensure_ai_vault_workspace()?; + let (index, items) = vault_store::list_credentials(&root, status)?; + let projected: Vec = items + .iter() + .map(vault_store::project_list_entry) + .collect(); + Ok(json!({ + "schema": "mnote.vault.list.v1", + "status": status.as_str(), + "revision": index.revision, + "updatedAt": index.updated_at, + "items": projected, + "isAiVault": true, + "aiVaultActorId": ai_vault_actor_id(), + "vaultRole": "ai", + })) +} + +pub fn get_ai_vault_item(credential_id: &str) -> Result { + require_vault_enabled()?; + let root = ensure_ai_vault_workspace()?; + let record = vault_store::get_credential(&root, credential_id)?; + Ok(json!({ + "item": vault_store::project_item_l0(&record), + "isAiVault": true, + "aiVaultActorId": ai_vault_actor_id(), + })) +} + +fn origin_from_credential_url(url: Option<&str>) -> Result { + let raw = url + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "vault_login_url_required", + "条目缺少 url,无法推导登录 origin", + ) + })?; + // Accept full URL or host-like + let with_scheme = if raw.contains("://") { + raw.to_string() + } else { + format!("https://{raw}") + }; + let parsed = reqwest::Url::parse(&with_scheme).map_err(|e| { + WebError::bad_request_code( + "vault_login_url_invalid", + format!("无法解析 url: {e}"), + ) + })?; + let origin = format!( + "{}://{}", + parsed.scheme(), + parsed.host_str().unwrap_or("") + ); + if parsed.port().is_some() { + return Ok(format!( + "{}://{}:{}", + parsed.scheme(), + parsed.host_str().unwrap_or(""), + parsed.port().unwrap() + )); + } + if origin.ends_with("://") { + return Err(WebError::bad_request_code( + "vault_login_url_invalid", + "url 缺少 host", + )); + } + Ok(origin) +} + +fn pick_login_account(record: &vault_store::VaultCredentialRecord, preferred: &str) -> String { + let pref = preferred.trim().to_ascii_lowercase(); + let email = record.email.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let user = record + .username + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + match pref.as_str() { + "email" => email.or(user).unwrap_or("").to_string(), + "username" => user.or(email).unwrap_or("").to_string(), + _ => { + // auto: prefer email when present (mnote auth accepts either; email more reliable) + email.or(user).unwrap_or("").to_string() + } + } +} + +fn merge_set_cookie_into_header(existing: &str, set_cookies: &[String]) -> String { + use std::collections::BTreeMap; + let mut map: BTreeMap = BTreeMap::new(); + for part in existing.split(';') { + let p = part.trim(); + if p.is_empty() { + continue; + } + if let Some((k, v)) = p.split_once('=') { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + for sc in set_cookies { + // "name=value; Path=/; HttpOnly" → take name=value + let first = sc.split(';').next().unwrap_or("").trim(); + if let Some((k, v)) = first.split_once('=') { + map.insert(k.trim().to_string(), v.trim().to_string()); + } + } + map.iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("; ") +} + +fn default_session_expires_hours() -> i64 { + std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .filter(|h| *h > 0) + .unwrap_or(168) // 7 days +} + +fn session_expires_rfc3339_from_now() -> String { + let hours = default_session_expires_hours(); + let t = time::OffsetDateTime::now_utc() + time::Duration::hours(hours); + t.format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| vault_store::now_rfc3339()) +} + +/// Multi-agent login against AI password book: +/// reuse fresh session → else api_first password login → save session. +/// Cloudflare / captcha → human_required (agent must capture session via browser tools). +pub fn login_ai_vault_credential( + credential_id: &str, + force_refresh: bool, + actor: &str, + request_id: Option<&str>, +) -> Result { + require_vault_enabled()?; + let root = ensure_ai_vault_workspace()?; + let mut record = vault_store::get_credential(&root, credential_id)?; + if record.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_login_inactive", + "只能登录在用条目", + )); + } + + // Ensure playbook exists + if record.login_playbook.is_none() { + let pb = vault_store::VaultLoginPlaybook::default_for_credential( + record.email.as_deref(), + record.username.as_deref(), + ); + record = vault_store::put_login_playbook(&root, credential_id, pb)?; + } + let playbook = record.login_playbook.clone().unwrap_or_else(|| { + vault_store::VaultLoginPlaybook::default_for_credential( + record.email.as_deref(), + record.username.as_deref(), + ) + }); + + if playbook.is_human_required() && !force_refresh { + // Still allow reuse of existing fresh session + if let Some(sess) = record.login_session.as_ref() { + if vault_store::login_session_is_fresh(sess) { + let cookie = sess.cookie_header.clone().unwrap_or_default(); + let _ = vault_store::append_vault_audit( + &root, + "login_reuse", + actor, + credential_id, + Some("session"), + request_id, + true, + ); + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": true, + "mode": "session", + "cookieHeader": cookie, + "expiresAt": sess.expires_at, + "loginPlaybook": playbook, + "transcriptHint": "已复用登录态", + "note": "playbook=human_required;有可用 session 直接复用", + })); + } + } + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": false, + "mode": "human_required", + "ok": false, + "code": "vault_login_human_required", + "message": "需要人完成验证码/Cloudflare 后回写登录态", + "loginPlaybook": playbook, + "humanInstructions": { + "local": "用 chrome-bridge 打开 loginUrl 完成验证并登录,然后 POST /api/vault/ai/items/{id}/session 提交 cookieHeader", + "remote": "用 Paseo 浏览器工具完成验证后同样调用 session 回写", + "sessionEndpoint": format!("/api/vault/ai/items/{credential_id}/session"), + }, + "transcriptHint": "需要人工验证后回写 session", + })); + } + + // Reuse fresh session unless force_refresh + if !force_refresh { + if let Some(sess) = record.login_session.as_ref() { + if vault_store::login_session_is_fresh(sess) { + let cookie = sess.cookie_header.clone().unwrap_or_default(); + let _ = vault_store::append_vault_audit( + &root, + "login_reuse", + actor, + credential_id, + Some("session"), + request_id, + true, + ); + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": true, + "mode": "session", + "cookieHeader": cookie, + "expiresAt": sess.expires_at, + "source": sess.source, + "loginPlaybook": { + "mode": playbook.mode, + "preferredAccount": playbook.preferred_account, + }, + "accountHint": pick_login_account(&record, &playbook.preferred_account), + "url": record.url, + "transcriptHint": "已复用登录态", + })); + } + } + } + + // Need password login + let password_resolved = resolve_ai_vault_secret( + credential_id, + "password", + actor, + request_id, + )?; + if password_resolved + .get("resolved") + .and_then(Value::as_bool) + != Some(true) + { + return Err(WebError::bad_request_code( + "vault_login_no_password", + "条目无可用 password,无法自动登录", + )); + } + let password = password_resolved + .get("value") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let account = pick_login_account(&record, &playbook.preferred_account); + if account.is_empty() { + return Err(WebError::bad_request_code( + "vault_login_no_account", + "条目缺少 email/username", + )); + } + + let mode = playbook.mode.trim().to_ascii_lowercase(); + if mode == "browser" { + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": false, + "mode": "browser", + "ok": false, + "code": "vault_login_browser_required", + "message": "playbook 要求浏览器登录;agent 用 chrome-bridge/Paseo 浏览器按 selectors 填表后 session 回写", + "loginPlaybook": playbook, + "account": account, + "passwordResolved": true, + // password not returned here — agent should resolve only if needed for fill + "url": record.url, + "transcriptHint": "需要浏览器按 playbook 登录后回写 session", + "humanInstructions": { + "local": "chrome-bridge 登录后 POST session", + "sessionEndpoint": format!("/api/vault/ai/items/{credential_id}/session"), + }, + })); + } + + // api_first (default) + let origin = origin_from_credential_url(record.url.as_deref())?; + let api_path = playbook + .api_path + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("/api/auth"); + let login_url = if api_path.starts_with("http") { + api_path.to_string() + } else { + format!( + "{}{}", + origin.trim_end_matches('/'), + if api_path.starts_with('/') { + api_path.to_string() + } else { + format!("/{api_path}") + } + ) + }; + + let body = json!({ + "action": "auth:signIn", + "args": { + "provider": "password", + "params": { + "password": password, + "flow": "signIn", + "account": account, + "email": if account.contains('@') { Value::String(account.clone()) } else { Value::Null }, + "name": if account.contains('@') { + account.split('@').next().unwrap_or(&account) + } else { + account.as_str() + }, + } + } + }); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| { + WebError::bad_request_code("vault_login_client_failed", format!("HTTP 客户端: {e}")) + })?; + + let response = client + .post(&login_url) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&body) + .send() + .map_err(|e| { + let msg = e.to_string(); + if msg.to_ascii_lowercase().contains("cloudflare") + || msg.to_ascii_lowercase().contains("403") + { + WebError::bad_request_code( + "vault_login_human_required", + format!("登录请求失败,可能需人机验证: {e}"), + ) + } else { + WebError::bad_request_code( + "vault_login_http_failed", + format!("登录请求失败: {e}"), + ) + } + })?; + + let status = response.status(); + let set_cookies: Vec = response + .headers() + .get_all(reqwest::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok().map(str::to_string)) + .collect(); + let resp_text = response.text().unwrap_or_default(); + + // Detect challenge pages + let lower = resp_text.to_ascii_lowercase(); + if status.as_u16() == 403 + || lower.contains("cloudflare") + || lower.contains("cf-challenge") + || lower.contains("captcha") + || lower.contains("just a moment") + { + let _ = vault_store::append_vault_audit( + &root, + "login_challenge", + actor, + credential_id, + Some("human"), + request_id, + false, + ); + return Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": false, + "mode": "human_required", + "ok": false, + "code": "vault_login_human_required", + "message": "检测到验证码/Cloudflare/拦截,需人完成验证后回写 session", + "httpStatus": status.as_u16(), + "loginPlaybook": playbook, + "url": record.url, + "transcriptHint": "需要人工验证后回写 session", + "humanInstructions": { + "local": "chrome-bridge 完成验证登录 → POST /api/vault/ai/items/{id}/session", + "remote": "Paseo 浏览器工具同理", + "sessionEndpoint": format!("/api/vault/ai/items/{credential_id}/session"), + }, + })); + } + + let mut json_ok = false; + if let Ok(v) = serde_json::from_str::(&resp_text) { + if v.get("error").is_none() && (status.is_success() || status.as_u16() == 200) { + json_ok = true; + } + if let Some(err) = v.get("error").and_then(Value::as_str) { + let _ = vault_store::append_vault_audit( + &root, + "login_failed", + actor, + credential_id, + Some("api"), + request_id, + false, + ); + return Err(WebError::bad_request_code( + "vault_login_rejected", + format!("远端登录拒绝: {err}"), + )); + } + } + if !status.is_success() && !json_ok && set_cookies.is_empty() { + let _ = vault_store::append_vault_audit( + &root, + "login_failed", + actor, + credential_id, + Some("api"), + request_id, + false, + ); + return Err(WebError::bad_request_code( + "vault_login_rejected", + format!("远端登录失败 HTTP {}", status.as_u16()), + )); + } + + let cookie_header = merge_set_cookie_into_header("", &set_cookies); + if cookie_header.is_empty() { + // Some auth endpoints set cookies via other means; still fail if none + return Err(WebError::bad_request_code( + "vault_login_no_cookie", + "登录响应未包含 Set-Cookie;请改用浏览器登录并 session 回写", + )); + } + + let now = time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "now".into()); + let expires = session_expires_rfc3339_from_now(); + let session = vault_store::VaultLoginSession { + cookie_header: Some(cookie_header.clone()), + expires_at: Some(expires.clone()), + last_login_at: Some(now), + source: Some("api".into()), + }; + let _ = vault_store::put_login_session(&root, credential_id, session)?; + let _ = vault_store::append_vault_audit( + &root, + "login_ok", + actor, + credential_id, + Some("api"), + request_id, + true, + ); + + Ok(json!({ + "schema": "mnote.vault.login.v1", + "id": credential_id, + "reused": false, + "mode": "api", + "ok": true, + "cookieHeader": cookie_header, + "expiresAt": expires, + "account": account, + "url": record.url, + "loginPlaybook": { + "mode": playbook.mode, + "preferredAccount": playbook.preferred_account, + }, + "transcriptHint": "已登录并保存登录态", + "note": "下次同 id 调用 login 将优先复用 session,直到过期或 forceRefresh", + })) +} + +/// Write session cookies captured by human/browser tools. +pub fn put_ai_vault_session( + credential_id: &str, + cookie_header: &str, + expires_at: Option<&str>, + source: &str, + actor: &str, + request_id: Option<&str>, +) -> Result { + require_vault_enabled()?; + let root = ensure_ai_vault_workspace()?; + let cookie = cookie_header.trim(); + if cookie.is_empty() { + return Err(WebError::bad_request_code( + "vault_session_cookie_required", + "cookieHeader 不能为空", + )); + } + let now = time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "now".into()); + let expires = expires_at + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(session_expires_rfc3339_from_now); + let session = vault_store::VaultLoginSession { + cookie_header: Some(cookie.to_string()), + expires_at: Some(expires.clone()), + last_login_at: Some(now), + source: Some(source.trim().to_string()), + }; + let record = vault_store::put_login_session(&root, credential_id, session)?; + let _ = vault_store::append_vault_audit( + &root, + "session_put", + actor, + credential_id, + Some(source), + request_id, + true, + ); + Ok(json!({ + "schema": "mnote.vault.session.v1", + "id": credential_id, + "ok": true, + "hasLoginSession": true, + "expiresAt": expires, + "source": source, + "item": vault_store::project_item_l0(&record), + "transcriptHint": "已回写登录态", + })) +} + +/// POST /api/vault/items/{id}/resolve — agent resolve; **AI vault only**. +pub async fn resolve_item( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let field = map + .get("field") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "vault_resolve_field_required", + "resolve 需要 field: password|apikey|token", + ) + })?; + + // Optional rootUri: if provided must be AI vault; if omitted use AI vault. + if let Some(root_uri) = map + .get("rootUri") + .or_else(|| map.get("root_uri")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + let root = resolve_read_root(&state, &context, root_uri).await?; + if !is_ai_vault_workspace(&root) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "vault_resolve_ai_only", + "resolve 仅允许 AI 密码本(多 agent 唯一策略);用户 vault 请用 reveal", + )); + } + } + + let result = resolve_ai_vault_secret( + &id, + field, + &actor, + Some(context.trace.request_id.as_str()), + )?; + Ok(ok_response(&context, result)) +} + +/// GET /api/vault/ai/list — multi-agent list of AI password book (no rootUri). +pub async fn list_ai( + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + require_authenticated(&context)?; + let status = parse_status(query.status.as_deref())?; + let result = list_ai_vault_items(status)?; + Ok(ok_response(&context, result)) +} + +/// GET /api/vault/ai/items/{id} +pub async fn get_ai_item( + Extension(context): Extension, + Path(id): Path, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + require_authenticated(&context)?; + let result = get_ai_vault_item(&id)?; + Ok(ok_response(&context, result)) +} + +/// POST /api/vault/ai/items/{id}/resolve — same as resolve, always AI vault. +pub async fn resolve_ai_item( + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let field = map + .get("field") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "vault_resolve_field_required", + "resolve 需要 field: password|apikey|token", + ) + })?; + let result = resolve_ai_vault_secret( + &id, + field, + &actor, + Some(context.trace.request_id.as_str()), + )?; + Ok(ok_response(&context, result)) +} + +/// POST /api/vault/ai/items/{id}/login +/// body: { forceRefresh?: bool } +pub async fn login_ai_item( + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let force = body + .get("forceRefresh") + .or_else(|| body.get("force_refresh")) + .and_then(Value::as_bool) + .unwrap_or(false); + let request_id = context.trace.request_id.clone(); + let id_clone = id.clone(); + let actor_clone = actor.clone(); + let result = tokio::task::spawn_blocking(move || { + login_ai_vault_credential(&id_clone, force, &actor_clone, Some(request_id.as_str())) + }) + .await + .map_err(|e| { + WebError::bad_request_code( + "vault_login_join_failed", + format!("login 任务失败: {e}"), + ) + })??; + Ok(ok_response(&context, result)) +} + +/// POST /api/vault/ai/items/{id}/session — human/browser cookie write-back +/// body: { cookieHeader, expiresAt?, source? } +pub async fn put_ai_session( + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let cookie = body + .get("cookieHeader") + .or_else(|| body.get("cookie_header")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "vault_session_cookie_required", + "需要 cookieHeader(浏览器登录后的 Cookie 头)", + ) + })?; + let expires = body + .get("expiresAt") + .or_else(|| body.get("expires_at")) + .and_then(Value::as_str); + let source = body + .get("source") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("human_bridge"); + let result = put_ai_vault_session( + &id, + cookie, + expires, + source, + &actor, + Some(context.trace.request_id.as_str()), + )?; + Ok(ok_response(&context, result)) +} + +/// POST /api/vault/items/{id}/share-to-ai +/// Clone credential into the AI actor's default workspace vault. +pub async fn share_to_ai( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let root_uri = require_root_uri( + map.get("rootUri") + .and_then(Value::as_str) + .or_else(|| map.get("root_uri").and_then(Value::as_str)), + )?; + let root = resolve_write_root(&state, &context, root_uri).await?; + + let target_actor = map + .get("targetActorId") + .or_else(|| map.get("target_actor_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(ai_vault_actor_id); + + // Prevent no-op self-share when already on AI vault (optional UX). + let target_root = + local_folder_source::managed_default_workspace_root_for_actor(&target_actor); + if !target_root.exists() { + // Create managed default workspace for AI actor if missing. + let _ = local_folder_source::create_default_local_workspace_for_actor( + &target_actor, + "user", + )?; + } + let target_root = target_root.canonicalize().map_err(|e| { + WebError::bad_request_code( + "vault_ai_root_unavailable", + format!("无法访问 AI 密码本工作区: {e}"), + ) + })?; + if target_root == root { + return Err(WebError::bad_request_code( + "vault_share_same_workspace", + "当前已是目标密码本工作区,无需共享", + )); + } + + let folder_override = map + .get("folderPath") + .or_else(|| map.get("folder_path")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| Some(format!("from-{}", actor))); + + // Default true: copy missing [Key] fragments into AI cipher-book so templates resolve. + // Existing non-empty target keys are never overwritten. + let sync_cipher_keys = map + .get("syncCipherKeys") + .or_else(|| map.get("sync_cipher_keys")) + .and_then(Value::as_bool) + .unwrap_or(true); + + let outcome = vault_store::share_credential_to_workspace( + &root, + &target_root, + &id, + folder_override.as_deref(), + &[], + sync_cipher_keys, + &actor, + &target_actor, + )?; + let shared = &outcome.item; + + let _ = vault_store::append_vault_audit( + &root, + if outcome.updated_existing { + "share_to_ai_sync" + } else { + "share_to_ai" + }, + &actor, + &id, + Some(target_actor.as_str()), + Some(context.trace.request_id.as_str()), + true, + ); + let _ = vault_store::append_vault_audit( + &target_root, + "share_from", + &actor, + &shared.id, + Some(id.as_str()), + Some(context.trace.request_id.as_str()), + true, + ); + + Ok(ok_response( + &context, + json!({ + "schema": "mnote.vault.shareToAi.v1", + "sourceId": id, + "targetActorId": target_actor, + "targetRootPath": target_root.to_string_lossy(), + "updatedExisting": outcome.updated_existing, + "item": vault_store::project_item_l0(shared), + "sourceItem": vault_store::project_item_l0(&outcome.source), + "cipherSync": { + "enabled": sync_cipher_keys, + "copiedKeys": outcome.cipher_keys_copied, + "skippedExistingKeys": outcome.cipher_keys_skipped_existing, + "missingInSource": outcome.cipher_keys_missing_in_source, + }, + }), + )) +} + +/// POST /api/vault/items/{id}/unshare-from-ai +/// Clear share-to-AI link on source and soft-delete the AI password-book copy. +pub async fn unshare_from_ai( + State(state): State, + Extension(context): Extension, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let root_uri = require_root_uri( + map.get("rootUri") + .and_then(Value::as_str) + .or_else(|| map.get("root_uri").and_then(Value::as_str)), + )?; + let root = resolve_write_root(&state, &context, root_uri).await?; + + // Prefer body target, else link on source, else default AI actor. + let source_peek = vault_store::get_credential(&root, &id)?; + let target_actor = map + .get("targetActorId") + .or_else(|| map.get("target_actor_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| { + source_peek + .shared_to_ai + .as_ref() + .map(|l| l.target_actor_id.clone()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(ai_vault_actor_id); + + let target_root = + local_folder_source::managed_default_workspace_root_for_actor(&target_actor); + if target_root.exists() { + let canonical = target_root.canonicalize().map_err(|e| { + WebError::bad_request_code( + "vault_ai_root_unavailable", + format!("无法访问 AI 密码本工作区: {e}"), + ) + })?; + if canonical == root { + return Err(WebError::bad_request_code( + "vault_unshare_same_workspace", + "当前已是目标密码本工作区,无法按共享链路撤销", + )); + } + } + + let outcome = vault_store::unshare_credential_from_ai(&root, &target_root, &id)?; + + if outcome.was_shared { + let _ = vault_store::append_vault_audit( + &root, + "unshare_from_ai", + &actor, + &id, + Some(target_actor.as_str()), + Some(context.trace.request_id.as_str()), + true, + ); + if let Some(target_id) = outcome.removed_target_id.as_deref() { + if outcome.target_deleted && target_root.exists() { + let _ = vault_store::append_vault_audit( + &target_root, + "unshare_soft_delete", + &actor, + target_id, + Some(id.as_str()), + Some(context.trace.request_id.as_str()), + true, + ); + } + } + } + + Ok(ok_response( + &context, + json!({ + "schema": "mnote.vault.unshareFromAi.v1", + "sourceId": id, + "targetActorId": target_actor, + "wasShared": outcome.was_shared, + "targetDeleted": outcome.target_deleted, + "removedTargetId": outcome.removed_target_id, + "sourceItem": vault_store::project_item_l0(&outcome.source), + }), + )) +} + +/// GET /api/vault/cipher-book — L0 keys only (no fragment plaintext). +pub async fn list_cipher_book( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + require_authenticated(&context)?; + let root_uri = require_root_uri(query.root_uri.as_deref())?; + let root = resolve_read_root(&state, &context, root_uri).await?; + let book = vault_store::load_cipher_book(&root)?; + Ok(ok_response( + &context, + vault_store::project_cipher_book_l0(&book), + )) +} + +/// PUT /api/vault/cipher-book/{key} — set fragment value. +pub async fn put_cipher_key( + State(state): State, + Extension(context): Extension, + Path(key): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let root_uri = require_root_uri( + map.get("rootUri") + .and_then(Value::as_str) + .or_else(|| map.get("root_uri").and_then(Value::as_str)), + )?; + let root = resolve_write_root(&state, &context, root_uri).await?; + let value = map + .get("value") + .and_then(Value::as_str) + .ok_or_else(|| { + WebError::bad_request_code("vault_cipher_value_required", "需要 value 字符串") + })?; + let book = vault_store::set_cipher_fragment(&root, &key, value)?; + let _ = vault_store::append_vault_audit( + &root, + "cipher_set", + &actor, + &format!("cipher:{key}"), + Some("fragment"), + Some(context.trace.request_id.as_str()), + true, + ); + Ok(ok_response( + &context, + vault_store::project_cipher_book_l0(&book), + )) +} + +/// DELETE /api/vault/cipher-book/{key} +pub async fn delete_cipher_key( + State(state): State, + Extension(context): Extension, + Path(key): Path, + Query(query): Query, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let root_uri = require_root_uri(query.root_uri.as_deref())?; + let root = resolve_write_root(&state, &context, root_uri).await?; + let book = vault_store::delete_cipher_fragment(&root, &key)?; + let _ = vault_store::append_vault_audit( + &root, + "cipher_delete", + &actor, + &format!("cipher:{key}"), + Some("fragment"), + Some(context.trace.request_id.as_str()), + true, + ); + Ok(ok_response( + &context, + vault_store::project_cipher_book_l0(&book), + )) +} + +/// POST /api/vault/cipher-book/{key}/reveal — show one fragment (audited). +pub async fn reveal_cipher_key( + State(state): State, + Extension(context): Extension, + Path(key): Path, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + require_vault_enabled()?; + let actor = require_authenticated(&context)?; + let map = body.as_object().ok_or_else(|| { + WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象") + })?; + let root_uri = require_root_uri( + map.get("rootUri") + .and_then(Value::as_str) + .or_else(|| map.get("root_uri").and_then(Value::as_str)), + )?; + let root = resolve_read_root(&state, &context, root_uri).await?; + let value = match vault_store::get_cipher_fragment(&root, &key) { + Ok(v) => v, + Err(err) => { + let _ = vault_store::append_vault_audit( + &root, + "cipher_reveal", + &actor, + &format!("cipher:{key}"), + Some("fragment"), + Some(context.trace.request_id.as_str()), + false, + ); + return Err(err); + } + }; + let _ = vault_store::append_vault_audit( + &root, + "cipher_reveal", + &actor, + &format!("cipher:{key}"), + Some("fragment"), + Some(context.trace.request_id.as_str()), + true, + ); + Ok(ok_response( + &context, + json!({ + "schema": "mnote.vault.cipherFragment.v1", + "key": key, + "secret": { + "state": "revealed", + "value": value, + }, + }), + )) +} + +/// Used by SSR to seed list without secrets. +pub fn bootstrap_list_json( + workspace_root: &std::path::Path, + status: VaultItemStatus, +) -> Result { + let (index, items) = vault_store::list_credentials(workspace_root, status)?; + let projected: Vec = items + .iter() + .map(vault_store::project_list_entry) + .collect(); + Ok(json!({ + "schema": "mnote.vault.list.v1", + "status": status.as_str(), + "revision": index.revision, + "updatedAt": index.updated_at, + "items": projected, + })) +} + +// silence unused helper (workspace_root_from_uri kept for future absolute checks) +#[allow(dead_code)] +fn _use_workspace_root_from_uri(root_uri: &str) -> Result { + workspace_root_from_uri(root_uri) +} + +#[cfg(test)] +mod resolve_strategy_tests { + use super::*; + use crate::routes::vault_store::{ + create_credential, set_cipher_fragment, VaultCreateInput, + }; + use std::sync::Mutex; + + /// Serialize tests that touch the shared AI vault path. + static AI_VAULT_TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn resolve_rejects_invalid_field_before_io() { + let err = resolve_ai_vault_secret("cred_x", "cookies", "tester", None).unwrap_err(); + assert_eq!(err.code(), "vault_resolve_field_invalid"); + } + + #[test] + fn resolve_ai_vault_expands_cipher_and_lists() { + let _guard = AI_VAULT_TEST_LOCK.lock().unwrap(); + let root = ensure_ai_vault_workspace().expect("ai vault root"); + set_cipher_fragment(&root, "Z", "frag").unwrap(); + let created = create_credential( + &root, + VaultCreateInput { + title: "cli-strategy-test".into(), + password: Some("pre[Z]post".into()), + tags: vec!["ai-shared".into()], + ..Default::default() + }, + ) + .unwrap(); + let resolved = + resolve_ai_vault_secret(&created.id, "password", "tester", Some("req-1")).unwrap(); + assert_eq!(resolved["resolved"], true); + assert_eq!(resolved["value"], "prefragpost"); + assert_eq!(resolved["transcriptHint"], "已解析 password"); + let list = list_ai_vault_items(VaultItemStatus::Active).unwrap(); + assert!(list["isAiVault"].as_bool().unwrap_or(false)); + let items = list["items"].as_array().unwrap(); + assert!(items.iter().any(|i| i["id"] == created.id)); + // cleanup + let _ = vault_store::soft_delete_credential(&root, &created.id); + let _ = vault_store::purge_credential(&root, &created.id); + let _ = vault_store::delete_cipher_fragment(&root, "Z"); + } + + #[test] + fn login_reuses_saved_session_without_http() { + let _guard = AI_VAULT_TEST_LOCK.lock().unwrap(); + let root = ensure_ai_vault_workspace().expect("ai vault root"); + let created = create_credential( + &root, + VaultCreateInput { + title: "session-reuse-test".into(), + url: Some("https://example.com/auth".into()), + email: Some("a@example.com".into()), + password: Some("secret".into()), + tags: vec!["ai-shared".into()], + ..Default::default() + }, + ) + .unwrap(); + let _ = vault_store::put_login_playbook( + &root, + &created.id, + vault_store::VaultLoginPlaybook::default_for_credential( + Some("a@example.com"), + None, + ), + ); + let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(2)) + .format(&time::format_description::well_known::Rfc3339) + .unwrap(); + put_ai_vault_session( + &created.id, + "mnote_session=abc; other=1", + Some(&expires), + "api", + "tester", + None, + ) + .unwrap(); + let out = login_ai_vault_credential(&created.id, false, "tester", None).unwrap(); + assert_eq!(out["reused"], true); + assert_eq!(out["mode"], "session"); + assert_eq!(out["cookieHeader"], "mnote_session=abc; other=1"); + // force refresh would try HTTP — skip + let _ = vault_store::soft_delete_credential(&root, &created.id); + let _ = vault_store::purge_credential(&root, &created.id); + } +} diff --git a/rust/crates/mnote-web/src/routes/vault_path.rs b/rust/crates/mnote-web/src/routes/vault_path.rs new file mode 100644 index 00000000..1b96044f --- /dev/null +++ b/rust/crates/mnote-web/src/routes/vault_path.rs @@ -0,0 +1,103 @@ +//! Shared vault path deny predicate for general file surfaces. +//! +//! Any workspace-relative path under `.mnote/vault` must be rejected on +//! open/stat/resource/local_file surfaces. Vault content is only reachable +//! via `/api/vault/*`. + +use crate::error::WebError; +use axum::http::StatusCode; + +/// Workspace-root-relative path that points at the password vault system space. +/// +/// `rel` should use `/` separators. Leading `./` is stripped. Parent segments +/// (`..`) are rejected before classification (callers usually already do this). +pub fn normalize_workspace_relative_path(rel: &str) -> String { + let mut value = rel.trim().replace('\\', "/"); + while value.starts_with("./") { + value = value[2..].to_string(); + } + value = value.trim_start_matches('/').to_string(); + while value.contains("//") { + value = value.replace("//", "/"); + } + if value.ends_with('/') && value != "/" { + value.pop(); + } + value +} + +/// Returns true when `rel` is `.mnote/vault` or any path under it. +pub fn is_vault_sensitive_relative_path(rel: &str) -> bool { + let n = normalize_workspace_relative_path(rel); + if n.is_empty() { + return false; + } + if n + .split('/') + .any(|segment| segment == ".." || segment.is_empty()) + { + // Escape / empty segments are not vault matches; callers reject escape. + return false; + } + n == ".mnote/vault" || n.starts_with(".mnote/vault/") +} + +/// 403 with stable code for general file surfaces that hit vault paths. +pub fn vault_path_denied_error(message: impl Into) -> WebError { + WebError::new( + StatusCode::FORBIDDEN, + "vault_path_denied", + message.into(), + ) +} + +/// Reject vault-relative paths before reading or writing general local files. +pub fn deny_if_vault_sensitive_relative_path(rel: &str) -> Result<(), WebError> { + if is_vault_sensitive_relative_path(rel) { + return Err(vault_path_denied_error( + "密码箱路径不能通过通用文件接口访问;请使用 /vault 或 /api/vault/*", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_vault_root_and_children() { + assert!(is_vault_sensitive_relative_path(".mnote/vault")); + assert!(is_vault_sensitive_relative_path(".mnote/vault/")); + assert!(is_vault_sensitive_relative_path(".mnote/vault/entries/x.md")); + assert!(is_vault_sensitive_relative_path( + "./.mnote/vault/entries/x.md" + )); + assert!(is_vault_sensitive_relative_path( + ".mnote\\vault\\entries\\x.md" + )); + assert!(is_vault_sensitive_relative_path( + ".mnote/vault/trash/entries/x.md" + )); + } + + #[test] + fn allows_non_vault_paths() { + assert!(!is_vault_sensitive_relative_path("")); + assert!(!is_vault_sensitive_relative_path("notes/a.md")); + assert!(!is_vault_sensitive_relative_path(".mnote/trash/a.md")); + assert!(!is_vault_sensitive_relative_path(".mnote/index/search-index.json")); + assert!(!is_vault_sensitive_relative_path("mnote/vault/x.md")); + assert!(!is_vault_sensitive_relative_path(".mnote/vault-backup/x.md")); + assert!(!is_vault_sensitive_relative_path("个人/密码/a.md")); + } + + #[test] + fn deny_helper_returns_stable_code() { + let err = deny_if_vault_sensitive_relative_path(".mnote/vault/entries/x.md") + .expect_err("must deny"); + assert_eq!(err.code(), "vault_path_denied"); + assert_eq!(err.status(), StatusCode::FORBIDDEN); + assert!(deny_if_vault_sensitive_relative_path("notes/a.md").is_ok()); + } +} diff --git a/rust/crates/mnote-web/src/routes/vault_store.rs b/rust/crates/mnote-web/src/routes/vault_store.rs new file mode 100644 index 00000000..fa3478e9 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/vault_store.rs @@ -0,0 +1,2993 @@ +//! Local-first password vault store under `{workspaceRoot}/.mnote/vault/`. +//! +//! Disk: per-entry Markdown + L0 `vault-index.json` + append-only `audit.jsonl` +//! + optional `cipher-book.json` (shared password fragments, e.g. `[A]` → fragment). +//! Secrets never appear in the index; API projections mask them by default. +//! +//! Same-site multi-account: no uniqueness on `url` — multiple credentials may share +//! a URL (different usernames). Optional `folderPath` (`a/b/c`) drives collapsible +//! tree grouping in the workbench (not a filesystem path under vault). + +use crate::error::WebError; +use crate::routes::local_markdown_parser::split_frontmatter; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; +use uuid::Uuid; + +pub const VAULT_INDEX_SCHEMA: &str = "mnote.vault.index.v1"; +pub const VAULT_CREDENTIAL_SCHEMA: &str = "mnote.vault.credential.v1"; +pub const VAULT_CIPHER_BOOK_SCHEMA: &str = "mnote.vault.cipherBook.v1"; +pub const VAULT_KIND: &str = "credential"; + +const FORBIDDEN_FIELD_KEYS: &[&str] = &[ + "password", + "token", + "apikey", + "api_key", + "secret", + "otp_secret", + "otpsecret", + "recovery", + "recovery_code", + "recoverycode", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VaultItemStatus { + Active, + Deleted, +} + +impl VaultItemStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Deleted => "deleted", + } + } + + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "active" => Some(Self::Active), + "deleted" => Some(Self::Deleted), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultAttachmentRef { + pub ref_id: String, + pub kind: String, + pub relative_path: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, +} + +/// Outbound link: this credential was shared to an AI password-book copy. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VaultShareToAi { + pub target_actor_id: String, + pub target_id: String, + pub shared_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_synced_at: Option, +} + +/// Inbound link: this credential is a copy shared from a user's vault. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VaultShareFrom { + pub source_actor_id: String, + pub source_id: String, + pub shared_at: String, +} + +/// How agents should log in with this credential (non-secret playbook). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultLoginPlaybook { + /// api_first | browser | human_required + #[serde(default)] + pub mode: String, + /// email | username | auto + #[serde(default)] + pub preferred_account: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submit_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub success_url_contains: Option, + /// Guidance when Cloudflare / captcha needs a human. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub human_note: Option, +} + +/// Captured browser/API session for multi-agent reuse (cookie header is secret). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultLoginSession { + /// Full Cookie request header value (secret). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cookie_header: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_login_at: Option, + /// api | browser | human_bridge + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +impl VaultLoginPlaybook { + pub fn default_for_credential(email: Option<&str>, username: Option<&str>) -> Self { + let preferred = if email.map(str::trim).filter(|s| !s.is_empty()).is_some() { + "email" + } else if username.map(str::trim).filter(|s| !s.is_empty()).is_some() { + "username" + } else { + "auto" + }; + Self { + mode: "api_first".into(), + preferred_account: preferred.into(), + api_path: Some("/api/auth".into()), + account_selector: Some("#account".into()), + password_selector: Some("#password".into()), + submit_selector: Some("button[type=submit]".into()), + success_url_contains: Some("/".into()), + human_note: Some( + "若 Cloudflare/验证码拦截:人用本机 chrome-bridge 或 Paseo 远程浏览器完成验证后,调用 vault session 回写 cookie。" + .into(), + ), + } + } + + pub fn is_human_required(&self) -> bool { + self.mode.trim().eq_ignore_ascii_case("human_required") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultCredentialRecord { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub apikey: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + /// Logical tree folder for UI grouping, e.g. `个人/银行/招商` (not a disk path). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_path: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub fields: BTreeMap, + #[serde(default)] + pub attachments: Vec, + #[serde(default)] + pub notes_markdown: String, + pub status: VaultItemStatus, + pub created_at: String, + pub updated_at: String, + pub relative_path: String, + #[serde(default)] + pub revision: u64, + /// Present on user vault entries that have been shared to AI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_to_ai: Option, + /// Present on AI vault copies cloned from a user credential. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_from: Option, + /// Agent login method (non-secret). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_playbook: Option, + /// Reusable login session (cookie secret). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_session: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultIndexEntry { + pub id: String, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_path: Option, + #[serde(default)] + pub tags: Vec, + pub has_password: bool, + pub has_apikey: bool, + pub has_token: bool, + pub attachment_count: usize, + pub relative_path: String, + pub updated_at: String, + pub status: VaultItemStatus, + #[serde(default)] + pub revision: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_to_ai: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shared_from: Option, + /// Non-secret: whether a login session cookie is stored. + #[serde(default)] + pub has_login_session: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_mode: Option, +} + +/// Shared password-fragment book: password templates use `[Key]` placeholders. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CipherBook { + pub schema: String, + pub revision: u64, + pub updated_at: String, + /// Key → plaintext fragment. Never projected in L0 list. + #[serde(default)] + pub fragments: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct ResolvedSecret { + pub value: String, + pub template: Option, + pub used_keys: Vec, + pub missing_keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VaultIndex { + pub schema: String, + pub revision: u64, + pub updated_at: String, + pub entries: BTreeMap, +} + +#[derive(Debug, Clone, Default)] +pub struct VaultCreateInput { + pub title: String, + pub url: Option, + pub username: Option, + pub password: Option, + pub password_hint: Option, + pub email: Option, + pub apikey: Option, + pub token: Option, + pub folder_path: Option, + pub tags: Vec, + pub fields: BTreeMap, + pub notes_markdown: String, +} + +/// Secret field patch: omit / keep / clear / set. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum SecretPatch { + #[default] + Omit, + Clear, + Set(String), +} + +#[derive(Debug, Clone, Default)] +pub struct VaultUpdateInput { + pub title: Option, + pub url: Option>, + pub username: Option>, + pub email: Option>, + pub password_hint: Option>, + pub folder_path: Option>, + pub tags: Option>, + pub fields: Option>, + pub notes_markdown: Option, + pub password: SecretPatch, + pub apikey: SecretPatch, + pub token: SecretPatch, + pub base_revision: Option, +} + +pub fn vault_root(workspace_root: &Path) -> PathBuf { + workspace_root.join(".mnote").join("vault") +} + +pub fn ensure_vault_directories(workspace_root: &Path) -> Result { + let root = vault_root(workspace_root); + for relative in [ + "", + "entries", + "attachments", + "trash", + "trash/entries", + "trash/attachments", + ] { + let path = if relative.is_empty() { + root.clone() + } else { + root.join(relative) + }; + fs::create_dir_all(&path).map_err(|error| { + WebError::bad_request_code( + "vault_ensure_failed", + format!("无法创建密码箱目录 {}: {error}", path.display()), + ) + })?; + } + Ok(root) +} + +pub fn now_rfc3339() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()) +} + +fn new_credential_id() -> String { + format!("cred_{}", Uuid::new_v4().simple()) +} + +fn index_path(vault_root: &Path) -> PathBuf { + vault_root.join("vault-index.json") +} + +fn audit_path(vault_root: &Path) -> PathBuf { + vault_root.join("audit.jsonl") +} + +fn cipher_book_path(vault_root: &Path) -> PathBuf { + vault_root.join("cipher-book.json") +} + +fn entry_rel_path(id: &str, status: VaultItemStatus) -> String { + match status { + VaultItemStatus::Active => format!("entries/{id}.md"), + VaultItemStatus::Deleted => format!("trash/entries/{id}.md"), + } +} + +fn abs_from_vault_rel(vault_root: &Path, relative: &str) -> Result { + let normalized = relative.replace('\\', "/"); + if normalized.is_empty() + || normalized.starts_with('/') + || normalized + .split('/') + .any(|part| part == ".." || part.is_empty()) + { + return Err(WebError::bad_request_code( + "vault_path_invalid", + "密码箱相对路径非法", + )); + } + let target = vault_root.join(&normalized); + if !target.starts_with(vault_root) { + return Err(WebError::bad_request_code( + "vault_path_escape", + "密码箱路径不能越过 vault root", + )); + } + Ok(target) +} + +pub fn append_vault_audit( + workspace_root: &Path, + action: &str, + actor_id: &str, + credential_id: &str, + field: Option<&str>, + request_id: Option<&str>, + ok: bool, +) -> Result<(), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let path = audit_path(&vault); + let mut line = json!({ + "ts": now_rfc3339(), + "action": action, + "actorId": actor_id, + "credentialId": credential_id, + "ok": ok, + }); + if let Some(field) = field { + line["field"] = json!(field); + } + if let Some(request_id) = request_id { + line["requestId"] = json!(request_id); + } + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| { + WebError::bad_request_code( + "vault_audit_write_failed", + format!("无法写入审计日志: {error}"), + ) + })?; + writeln!(file, "{line}").map_err(|error| { + WebError::bad_request_code( + "vault_audit_write_failed", + format!("无法追加审计日志: {error}"), + ) + })?; + Ok(()) +} + +fn empty_index() -> VaultIndex { + VaultIndex { + schema: VAULT_INDEX_SCHEMA.to_string(), + revision: 0, + updated_at: now_rfc3339(), + entries: BTreeMap::new(), + } +} + +fn load_index(vault_root: &Path) -> Result { + let path = index_path(vault_root); + if !path.exists() { + return Ok(empty_index()); + } + let content = fs::read_to_string(&path).map_err(|error| { + WebError::bad_request_code( + "vault_index_read_failed", + format!("无法读取密码箱索引: {error}"), + ) + })?; + match serde_json::from_str::(&content) { + Ok(index) => Ok(index), + Err(_) => rebuild_index(vault_root), + } +} + +fn write_index_atomic(vault_root: &Path, index: &VaultIndex) -> Result<(), WebError> { + let path = index_path(vault_root); + let tmp = vault_root.join("vault-index.json.tmp"); + let body = serde_json::to_string_pretty(index).map_err(|error| { + WebError::bad_request_code( + "vault_index_serialize_failed", + format!("无法序列化密码箱索引: {error}"), + ) + })?; + fs::write(&tmp, body).map_err(|error| { + WebError::bad_request_code( + "vault_index_write_failed", + format!("无法写入密码箱索引临时文件: {error}"), + ) + })?; + fs::rename(&tmp, &path).map_err(|error| { + WebError::bad_request_code( + "vault_index_write_failed", + format!("无法提交密码箱索引: {error}"), + ) + })?; + Ok(()) +} + +fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry { + VaultIndexEntry { + id: record.id.clone(), + title: record.title.clone(), + url: record.url.clone(), + username: record.username.clone(), + email: record.email.clone(), + password_hint: record.password_hint.clone(), + folder_path: record.folder_path.clone(), + tags: record.tags.clone(), + has_password: secret_present(record.password.as_deref()), + has_apikey: secret_present(record.apikey.as_deref()), + has_token: secret_present(record.token.as_deref()), + attachment_count: record.attachments.len(), + relative_path: record.relative_path.clone(), + updated_at: record.updated_at.clone(), + status: record.status, + revision: record.revision, + shared_to_ai: record.shared_to_ai.clone(), + shared_from: record.shared_from.clone(), + has_login_session: record + .login_session + .as_ref() + .and_then(|s| s.cookie_header.as_ref()) + .map(|c| !c.trim().is_empty()) + .unwrap_or(false), + session_expires_at: record + .login_session + .as_ref() + .and_then(|s| s.expires_at.clone()), + login_mode: record + .login_playbook + .as_ref() + .map(|p| p.mode.clone()) + .filter(|m| !m.is_empty()), + } +} + +/// Normalize logical folder path: trim, collapse `//`, strip leading/trailing `/`. +pub fn normalize_folder_path(raw: Option<&str>) -> Option { + let Some(s) = raw.map(str::trim).filter(|v| !v.is_empty()) else { + return None; + }; + let parts: Vec<&str> = s + .split(|c| c == '/' || c == '\\') + .map(str::trim) + .filter(|p| !p.is_empty() && *p != "." && *p != "..") + .collect(); + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } +} + +/// Validate cipher-book key: `[A-Za-z0-9_]+`, 1..=32 chars (matches `[Key]` in passwords). +pub fn validate_cipher_key(key: &str) -> Result<(), WebError> { + let key = key.trim(); + if key.is_empty() || key.len() > 32 { + return Err(WebError::bad_request_code( + "vault_cipher_key_invalid", + "密文簿 key 长度须为 1–32", + )); + } + if !key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(WebError::bad_request_code( + "vault_cipher_key_invalid", + "密文簿 key 仅允许字母、数字、下划线(密码中写 [A] 或 [Key])", + )); + } + Ok(()) +} + +fn empty_cipher_book() -> CipherBook { + CipherBook { + schema: VAULT_CIPHER_BOOK_SCHEMA.to_string(), + revision: 0, + updated_at: now_rfc3339(), + fragments: BTreeMap::new(), + } +} + +pub fn load_cipher_book(workspace_root: &Path) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let path = cipher_book_path(&vault); + if !path.exists() { + return Ok(empty_cipher_book()); + } + let content = fs::read_to_string(&path).map_err(|error| { + WebError::bad_request_code( + "vault_cipher_book_read_failed", + format!("无法读取密文簿: {error}"), + ) + })?; + match serde_json::from_str::(&content) { + Ok(book) => Ok(book), + Err(error) => Err(WebError::bad_request_code( + "vault_cipher_book_corrupt", + format!("密文簿损坏: {error}"), + )), + } +} + +fn write_cipher_book_atomic(vault_root: &Path, book: &CipherBook) -> Result<(), WebError> { + let path = cipher_book_path(vault_root); + let tmp = vault_root.join("cipher-book.json.tmp"); + let body = serde_json::to_string_pretty(book).map_err(|error| { + WebError::bad_request_code( + "vault_cipher_book_serialize_failed", + format!("无法序列化密文簿: {error}"), + ) + })?; + fs::write(&tmp, body).map_err(|error| { + WebError::bad_request_code( + "vault_cipher_book_write_failed", + format!("无法写入密文簿临时文件: {error}"), + ) + })?; + fs::rename(&tmp, &path).map_err(|error| { + WebError::bad_request_code( + "vault_cipher_book_write_failed", + format!("无法提交密文簿: {error}"), + ) + })?; + Ok(()) +} + +/// L0 projection: keys + hasValue only — never fragment plaintext. +pub fn project_cipher_book_l0(book: &CipherBook) -> Value { + let keys: Vec = book + .fragments + .iter() + .map(|(k, v)| { + json!({ + "key": k, + "hasValue": !v.is_empty(), + }) + }) + .collect(); + json!({ + "schema": VAULT_CIPHER_BOOK_SCHEMA, + "revision": book.revision, + "updatedAt": book.updated_at, + "keys": keys, + }) +} + +pub fn set_cipher_fragment( + workspace_root: &Path, + key: &str, + value: &str, +) -> Result { + validate_cipher_key(key)?; + if value.is_empty() { + return Err(WebError::bad_request_code( + "vault_cipher_value_required", + "密文片段不能为空;删除请用 DELETE", + )); + } + if is_mask_sentinel(value) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入密文簿", + )); + } + let vault = ensure_vault_directories(workspace_root)?; + let mut book = load_cipher_book(workspace_root)?; + book.fragments.insert(key.trim().to_string(), value.to_string()); + book.revision = book.revision.saturating_add(1); + book.updated_at = now_rfc3339(); + write_cipher_book_atomic(&vault, &book)?; + Ok(book) +} + +pub fn delete_cipher_fragment( + workspace_root: &Path, + key: &str, +) -> Result { + validate_cipher_key(key)?; + let vault = ensure_vault_directories(workspace_root)?; + let mut book = load_cipher_book(workspace_root)?; + if book.fragments.remove(key.trim()).is_none() { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "vault_cipher_key_not_found", + format!("密文簿中不存在 key: {key}"), + )); + } + book.revision = book.revision.saturating_add(1); + book.updated_at = now_rfc3339(); + write_cipher_book_atomic(&vault, &book)?; + Ok(book) +} + +pub fn get_cipher_fragment( + workspace_root: &Path, + key: &str, +) -> Result { + validate_cipher_key(key)?; + let book = load_cipher_book(workspace_root)?; + book.fragments + .get(key.trim()) + .filter(|v| !v.is_empty()) + .cloned() + .ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "vault_cipher_key_not_found", + format!("密文簿中不存在 key: {key}"), + ) + }) +} + +/// Expand `[Key]` placeholders using the cipher book. +/// Unknown keys are left as `[Key]` and listed in `missing_keys`. +pub fn resolve_cipher_placeholders(template: &str, book: &CipherBook) -> ResolvedSecret { + let bytes = template.as_bytes(); + let mut out = String::with_capacity(template.len()); + let mut used: Vec = Vec::new(); + let mut missing: Vec = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b'[' { + if let Some(end) = template[i + 1..].find(']') { + let key = &template[i + 1..i + 1 + end]; + if !key.is_empty() + && key.len() <= 32 + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + if let Some(frag) = book.fragments.get(key) { + out.push_str(frag); + if !used.iter().any(|k| k == key) { + used.push(key.to_string()); + } + } else { + out.push('['); + out.push_str(key); + out.push(']'); + if !missing.iter().any(|k| k == key) { + missing.push(key.to_string()); + } + } + i = i + 1 + end + 1; + continue; + } + } + } + out.push(bytes[i] as char); + i += 1; + } + let changed = out != template; + ResolvedSecret { + value: out, + template: if changed { + Some(template.to_string()) + } else { + None + }, + used_keys: used, + missing_keys: missing, + } +} + +pub fn resolve_secret_with_cipher_book( + workspace_root: &Path, + template: Option<&str>, +) -> Result, WebError> { + let Some(t) = template.filter(|v| !v.is_empty()) else { + return Ok(None); + }; + if !t.contains('[') { + return Ok(Some(ResolvedSecret { + value: t.to_string(), + template: None, + used_keys: Vec::new(), + missing_keys: Vec::new(), + })); + } + let book = load_cipher_book(workspace_root)?; + Ok(Some(resolve_cipher_placeholders(t, &book))) +} + +fn secret_present(value: Option<&str>) -> bool { + value.map(|v| !v.is_empty()).unwrap_or(false) +} + +fn validate_fields(fields: &BTreeMap) -> Result<(), WebError> { + for key in fields.keys() { + let lower = key.to_ascii_lowercase(); + if FORBIDDEN_FIELD_KEYS + .iter() + .any(|forbidden| lower.contains(forbidden)) + { + return Err(WebError::bad_request_code( + "vault_fields_secret_forbidden", + format!("fields 禁止密钥类键: {key};请使用 password / apikey / token"), + )); + } + } + Ok(()) +} + +fn validate_hint_not_secret( + hint: Option<&str>, + password: Option<&str>, +) -> Result<(), WebError> { + let hint = hint.map(str::trim).filter(|v| !v.is_empty()); + let password = password.map(str::trim).filter(|v| !v.is_empty()); + if let (Some(h), Some(p)) = (hint, password) { + if h == p { + return Err(WebError::bad_request_code( + "vault_hint_equals_secret", + "passwordHint 不能与 password 相同", + )); + } + } + Ok(()) +} + +pub fn is_mask_sentinel(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() { + return false; + } + let bullets = trimmed + .chars() + .all(|c| matches!(c, '•' | '*' | '●' | '○' | '·')); + bullets && trimmed.chars().count() >= 3 +} + +fn yaml_escape(value: &str) -> String { + if value.is_empty() { + return "\"\"".to_string(); + } + if value.chars().any(|c| { + c.is_whitespace() + || matches!(c, ':' | '#' | '"' | '\'' | '{' | '}' | '[' | ']' | ',' | '&' | '*' | '!' | '|' | '>' | '%' | '@' | '`') + }) { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + value.to_string() + } +} + +fn render_entry_markdown(record: &VaultCredentialRecord) -> String { + let mut out = String::from("---\n"); + out.push_str(&format!("mnote.kind: {VAULT_KIND}\n")); + out.push_str(&format!("schema: {VAULT_CREDENTIAL_SCHEMA}\n")); + out.push_str(&format!("id: {}\n", record.id)); + out.push_str(&format!("title: {}\n", yaml_escape(&record.title))); + if let Some(url) = &record.url { + out.push_str(&format!("url: {}\n", yaml_escape(url))); + } + if let Some(username) = &record.username { + out.push_str(&format!("username: {}\n", yaml_escape(username))); + } + if let Some(password) = &record.password { + out.push_str(&format!("password: {}\n", yaml_escape(password))); + } + if let Some(hint) = &record.password_hint { + out.push_str(&format!("passwordHint: {}\n", yaml_escape(hint))); + } + if let Some(email) = &record.email { + out.push_str(&format!("email: {}\n", yaml_escape(email))); + } + if let Some(apikey) = &record.apikey { + out.push_str(&format!("apikey: {}\n", yaml_escape(apikey))); + } + if let Some(token) = &record.token { + out.push_str(&format!("token: {}\n", yaml_escape(token))); + } + if let Some(folder) = &record.folder_path { + out.push_str(&format!("folderPath: {}\n", yaml_escape(folder))); + } + if record.tags.is_empty() { + out.push_str("tags: []\n"); + } else { + out.push_str("tags:\n"); + for tag in &record.tags { + out.push_str(&format!(" - {}\n", yaml_escape(tag))); + } + } + if record.fields.is_empty() { + out.push_str("fields: {}\n"); + } else { + out.push_str("fields:\n"); + for (k, v) in &record.fields { + out.push_str(&format!(" {}: {}\n", k, yaml_escape(v))); + } + } + if record.attachments.is_empty() { + out.push_str("attachments: []\n"); + } else { + out.push_str("attachments:\n"); + for att in &record.attachments { + out.push_str(&format!(" - refId: {}\n", yaml_escape(&att.ref_id))); + out.push_str(&format!(" kind: {}\n", yaml_escape(&att.kind))); + out.push_str(&format!( + " relativePath: {}\n", + yaml_escape(&att.relative_path) + )); + out.push_str(&format!(" label: {}\n", yaml_escape(&att.label))); + if let Some(ct) = &att.content_type { + out.push_str(&format!(" contentType: {}\n", yaml_escape(ct))); + } + } + } + if let Some(link) = &record.shared_to_ai { + out.push_str("sharedToAi:\n"); + out.push_str(&format!( + " targetActorId: {}\n", + yaml_escape(&link.target_actor_id) + )); + out.push_str(&format!(" targetId: {}\n", yaml_escape(&link.target_id))); + out.push_str(&format!(" sharedAt: {}\n", yaml_escape(&link.shared_at))); + if let Some(synced) = &link.last_synced_at { + out.push_str(&format!(" lastSyncedAt: {}\n", yaml_escape(synced))); + } + } + if let Some(origin) = &record.shared_from { + out.push_str("sharedFrom:\n"); + out.push_str(&format!( + " sourceActorId: {}\n", + yaml_escape(&origin.source_actor_id) + )); + out.push_str(&format!(" sourceId: {}\n", yaml_escape(&origin.source_id))); + out.push_str(&format!(" sharedAt: {}\n", yaml_escape(&origin.shared_at))); + } + if let Some(pb) = &record.login_playbook { + out.push_str("loginPlaybook:\n"); + if !pb.mode.is_empty() { + out.push_str(&format!(" mode: {}\n", yaml_escape(&pb.mode))); + } + if !pb.preferred_account.is_empty() { + out.push_str(&format!( + " preferredAccount: {}\n", + yaml_escape(&pb.preferred_account) + )); + } + if let Some(v) = &pb.api_path { + out.push_str(&format!(" apiPath: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.account_selector { + out.push_str(&format!(" accountSelector: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.password_selector { + out.push_str(&format!(" passwordSelector: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.submit_selector { + out.push_str(&format!(" submitSelector: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.success_url_contains { + out.push_str(&format!(" successUrlContains: {}\n", yaml_escape(v))); + } + if let Some(v) = &pb.human_note { + out.push_str(&format!(" humanNote: {}\n", yaml_escape(v))); + } + } + if let Some(sess) = &record.login_session { + out.push_str("loginSession:\n"); + if let Some(v) = &sess.cookie_header { + out.push_str(&format!(" cookieHeader: {}\n", yaml_escape(v))); + } + if let Some(v) = &sess.expires_at { + out.push_str(&format!(" expiresAt: {}\n", yaml_escape(v))); + } + if let Some(v) = &sess.last_login_at { + out.push_str(&format!(" lastLoginAt: {}\n", yaml_escape(v))); + } + if let Some(v) = &sess.source { + out.push_str(&format!(" source: {}\n", yaml_escape(v))); + } + } + out.push_str(&format!("createdAt: {}\n", record.created_at)); + out.push_str(&format!("updatedAt: {}\n", record.updated_at)); + out.push_str(&format!("revision: {}\n", record.revision)); + out.push_str(&format!("status: {}\n", record.status.as_str())); + out.push_str("---\n\n"); + if record.notes_markdown.trim().is_empty() { + out.push_str("## Notes\n\n"); + } else { + out.push_str(&record.notes_markdown); + if !record.notes_markdown.ends_with('\n') { + out.push('\n'); + } + } + out +} + +fn parse_scalar(raw: &str) -> String { + let trimmed = raw.trim(); + if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2) + { + let inner = &trimmed[1..trimmed.len() - 1]; + return inner.replace("\\\"", "\"").replace("\\\\", "\\"); + } + // strip inline comment + if let Some((value, _)) = trimmed.split_once(" #") { + return value.trim().to_string(); + } + trimmed.to_string() +} + +fn parse_frontmatter_map(frontmatter: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut lines = frontmatter.lines().peekable(); + while let Some(line) = lines.next() { + let trimmed = line.trim_end(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let Some((key, rest)) = trimmed.split_once(':') else { + continue; + }; + let key = key.trim().to_string(); + let rest = rest.trim(); + if rest.is_empty() { + // nested block or list + let mut nested_map = Map::new(); + let mut nested_list = Vec::new(); + let mut mode = "unknown"; + while let Some(peek) = lines.peek() { + let peek_trim = peek.trim_end(); + if peek_trim.is_empty() { + lines.next(); + continue; + } + let indent = peek.chars().take_while(|c| *c == ' ').count(); + if indent == 0 { + break; + } + let line = lines.next().unwrap(); + let content = line.trim(); + if content.starts_with("- ") { + mode = "list"; + let item = content[2..].trim(); + if item.contains(':') { + // object list item start + let mut obj = Map::new(); + if let Some((ik, iv)) = item.split_once(':') { + obj.insert(ik.trim().to_string(), Value::String(parse_scalar(iv))); + } + while let Some(peek2) = lines.peek() { + let p2 = peek2.trim_end(); + let indent2 = p2.chars().take_while(|c| *c == ' ').count(); + if indent2 <= indent || p2.trim().starts_with("- ") { + break; + } + let nested_line = lines.next().unwrap(); + let nested = nested_line.trim(); + if let Some((nk, nv)) = nested.split_once(':') { + obj.insert(nk.trim().to_string(), Value::String(parse_scalar(nv))); + } + } + nested_list.push(Value::Object(obj)); + } else { + nested_list.push(Value::String(parse_scalar(item))); + } + } else if let Some((nk, nv)) = content.split_once(':') { + mode = "map"; + nested_map.insert(nk.trim().to_string(), Value::String(parse_scalar(nv))); + } + } + if mode == "list" { + map.insert(key, Value::Array(nested_list)); + } else if mode == "map" { + map.insert(key, Value::Object(nested_map)); + } else { + map.insert(key, Value::String(String::new())); + } + } else if rest == "[]" { + map.insert(key, Value::Array(vec![])); + } else if rest == "{}" { + map.insert(key, Value::Object(Map::new())); + } else { + map.insert(key, Value::String(parse_scalar(rest))); + } + } + map +} + +fn string_field(map: &BTreeMap, keys: &[&str]) -> Option { + for key in keys { + if let Some(Value::String(value)) = map.get(*key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn parse_tags(map: &BTreeMap) -> Vec { + match map.get("tags") { + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| item.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()) + .collect(), + Some(Value::String(raw)) => raw + .split(',') + .map(|s| s.trim().trim_matches(|c| c == '[' || c == ']').to_string()) + .filter(|s| !s.is_empty()) + .collect(), + _ => Vec::new(), + } +} + +fn parse_fields(map: &BTreeMap) -> BTreeMap { + let mut out = BTreeMap::new(); + if let Some(Value::Object(obj)) = map.get("fields") { + for (k, v) in obj { + if let Some(s) = v.as_str() { + out.insert(k.clone(), s.to_string()); + } + } + } + out +} + +fn parse_attachments(map: &BTreeMap) -> Vec { + let Some(Value::Array(items)) = map.get("attachments") else { + return Vec::new(); + }; + items + .iter() + .filter_map(|item| { + let obj = item.as_object()?; + Some(VaultAttachmentRef { + ref_id: obj + .get("refId") + .or_else(|| obj.get("ref_id")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + kind: obj + .get("kind") + .and_then(Value::as_str) + .unwrap_or("other") + .to_string(), + relative_path: obj + .get("relativePath") + .or_else(|| obj.get("relative_path")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + label: obj + .get("label") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + content_type: obj + .get("contentType") + .or_else(|| obj.get("content_type")) + .and_then(Value::as_str) + .map(str::to_string), + }) + }) + .filter(|a| !a.ref_id.is_empty()) + .collect() +} + +fn nested_string(obj: &Map, keys: &[&str]) -> Option { + for key in keys { + if let Some(Value::String(value)) = obj.get(*key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn parse_share_to_ai(map: &BTreeMap) -> Option { + let obj = map + .get("sharedToAi") + .or_else(|| map.get("shared_to_ai"))? + .as_object()?; + let target_id = nested_string(obj, &["targetId", "target_id"])?; + let target_actor_id = + nested_string(obj, &["targetActorId", "target_actor_id"]).unwrap_or_default(); + let shared_at = nested_string(obj, &["sharedAt", "shared_at"]).unwrap_or_else(now_rfc3339); + let last_synced_at = nested_string(obj, &["lastSyncedAt", "last_synced_at"]); + Some(VaultShareToAi { + target_actor_id, + target_id, + shared_at, + last_synced_at, + }) +} + +fn parse_share_from(map: &BTreeMap) -> Option { + let obj = map + .get("sharedFrom") + .or_else(|| map.get("shared_from"))? + .as_object()?; + let source_id = nested_string(obj, &["sourceId", "source_id"])?; + let source_actor_id = + nested_string(obj, &["sourceActorId", "source_actor_id"]).unwrap_or_default(); + let shared_at = nested_string(obj, &["sharedAt", "shared_at"]).unwrap_or_else(now_rfc3339); + Some(VaultShareFrom { + source_actor_id, + source_id, + shared_at, + }) +} + +fn parse_login_playbook(map: &BTreeMap) -> Option { + let obj = map + .get("loginPlaybook") + .or_else(|| map.get("login_playbook"))? + .as_object()?; + let mode = nested_string(obj, &["mode"]).unwrap_or_else(|| "api_first".into()); + let preferred_account = + nested_string(obj, &["preferredAccount", "preferred_account"]).unwrap_or_else(|| "auto".into()); + Some(VaultLoginPlaybook { + mode, + preferred_account, + api_path: nested_string(obj, &["apiPath", "api_path"]), + account_selector: nested_string(obj, &["accountSelector", "account_selector"]), + password_selector: nested_string(obj, &["passwordSelector", "password_selector"]), + submit_selector: nested_string(obj, &["submitSelector", "submit_selector"]), + success_url_contains: nested_string(obj, &["successUrlContains", "success_url_contains"]), + human_note: nested_string(obj, &["humanNote", "human_note"]), + }) +} + +fn parse_login_session(map: &BTreeMap) -> Option { + let obj = map + .get("loginSession") + .or_else(|| map.get("login_session"))? + .as_object()?; + let cookie_header = nested_string(obj, &["cookieHeader", "cookie_header"]); + let expires_at = nested_string(obj, &["expiresAt", "expires_at"]); + let last_login_at = nested_string(obj, &["lastLoginAt", "last_login_at"]); + let source = nested_string(obj, &["source"]); + if cookie_header.is_none() + && expires_at.is_none() + && last_login_at.is_none() + && source.is_none() + { + return None; + } + Some(VaultLoginSession { + cookie_header, + expires_at, + last_login_at, + source, + }) +} + +fn parse_entry_markdown( + content: &str, + relative_path: &str, + status: VaultItemStatus, +) -> Result { + let (frontmatter, body) = split_frontmatter(content); + let Some(fm) = frontmatter else { + return Err(WebError::bad_request_code( + "vault_entry_invalid", + "密码箱条目缺少 frontmatter", + )); + }; + let map = parse_frontmatter_map(&fm); + let id = string_field(&map, &["id"]).ok_or_else(|| { + WebError::bad_request_code("vault_entry_invalid", "密码箱条目缺少 id") + })?; + let title = string_field(&map, &["title"]).unwrap_or_else(|| id.clone()); + let revision = string_field(&map, &["revision"]) + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + let status = string_field(&map, &["status"]) + .and_then(|v| VaultItemStatus::parse(&v)) + .unwrap_or(status); + Ok(VaultCredentialRecord { + id, + title, + url: string_field(&map, &["url"]), + username: string_field(&map, &["username"]), + password: string_field(&map, &["password"]), + password_hint: string_field(&map, &["passwordHint", "password_hint"]), + email: string_field(&map, &["email"]), + apikey: string_field(&map, &["apikey", "apiKey", "api_key"]), + token: string_field(&map, &["token"]), + folder_path: normalize_folder_path( + string_field(&map, &["folderPath", "folder_path", "group"]).as_deref(), + ), + tags: parse_tags(&map), + fields: parse_fields(&map), + attachments: parse_attachments(&map), + notes_markdown: body.trim_start_matches('\n').to_string(), + status, + created_at: string_field(&map, &["createdAt", "created_at"]) + .unwrap_or_else(now_rfc3339), + updated_at: string_field(&map, &["updatedAt", "updated_at"]) + .unwrap_or_else(now_rfc3339), + relative_path: relative_path.to_string(), + revision, + shared_to_ai: parse_share_to_ai(&map), + shared_from: parse_share_from(&map), + login_playbook: parse_login_playbook(&map), + login_session: parse_login_session(&map), + }) +} + +/// L0-only rebuild: never logs secret values. +pub fn rebuild_index(vault_root: &Path) -> Result { + let mut index = empty_index(); + let mut max_revision = 0u64; + for (status, subdir) in [ + (VaultItemStatus::Active, "entries"), + (VaultItemStatus::Deleted, "trash/entries"), + ] { + let dir = vault_root.join(subdir); + if !dir.is_dir() { + continue; + } + let read_dir = fs::read_dir(&dir).map_err(|error| { + WebError::bad_request_code( + "vault_reindex_failed", + format!("无法扫描密码箱目录: {error}"), + ) + })?; + for entry in read_dir.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + let file_name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("unknown.md"); + let relative = format!("{subdir}/{file_name}"); + let content = match fs::read_to_string(&path) { + Ok(v) => v, + Err(_) => { + // log only id/path/code — no body + eprintln!( + "vault_reindex_skip path={relative} code=vault_entry_read_failed" + ); + continue; + } + }; + match parse_entry_markdown(&content, &relative, status) { + Ok(record) => { + max_revision = max_revision.max(record.revision); + index + .entries + .insert(record.id.clone(), index_entry_from_record(&record)); + } + Err(error) => { + eprintln!( + "vault_reindex_skip path={relative} code={}", + error.code() + ); + } + } + } + } + index.revision = max_revision; + index.updated_at = now_rfc3339(); + write_index_atomic(vault_root, &index)?; + Ok(index) +} + +fn read_record_from_disk( + vault_root: &Path, + id: &str, + index: &VaultIndex, +) -> Result { + let entry = index.entries.get(id).ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "vault_item_not_found", + format!("密码箱条目不存在: {id}"), + ) + })?; + let path = abs_from_vault_rel(vault_root, &entry.relative_path)?; + let content = fs::read_to_string(&path).map_err(|_| { + WebError::new( + StatusCode::NOT_FOUND, + "vault_item_not_found", + format!("密码箱条目文件缺失: {id}"), + ) + })?; + parse_entry_markdown(&content, &entry.relative_path, entry.status) +} + +fn write_record_to_disk( + vault_root: &Path, + record: &VaultCredentialRecord, +) -> Result<(), WebError> { + let path = abs_from_vault_rel(vault_root, &record.relative_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "vault_write_failed", + format!("无法创建密码箱条目目录: {error}"), + ) + })?; + } + let body = render_entry_markdown(record); + let tmp = path.with_extension("md.tmp"); + fs::write(&tmp, body).map_err(|error| { + WebError::bad_request_code( + "vault_write_failed", + format!("无法写入密码箱条目: {error}"), + ) + })?; + fs::rename(&tmp, &path).map_err(|error| { + WebError::bad_request_code( + "vault_write_failed", + format!("无法提交密码箱条目: {error}"), + ) + })?; + Ok(()) +} + +pub fn list_credentials( + workspace_root: &Path, + status: VaultItemStatus, +) -> Result<(VaultIndex, Vec), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let index = load_index(&vault)?; + let mut items: Vec = index + .entries + .values() + .filter(|entry| entry.status == status) + .cloned() + .collect(); + items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at).then(a.title.cmp(&b.title))); + Ok((index, items)) +} + +pub fn get_credential( + workspace_root: &Path, + id: &str, +) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let index = load_index(&vault)?; + read_record_from_disk(&vault, id, &index) +} + +/// Outcome of sharing a credential across workspaces (e.g. user vault → AI vault). +#[derive(Debug, Clone)] +pub struct ShareCredentialOutcome { + pub item: VaultCredentialRecord, + /// Updated source record with `shared_to_ai` link. + pub source: VaultCredentialRecord, + /// True when an existing AI copy was updated instead of creating a new one. + pub updated_existing: bool, + /// Cipher keys newly written into the target cipher-book. + pub cipher_keys_copied: Vec, + /// Keys already present (non-empty) on target; not overwritten. + pub cipher_keys_skipped_existing: Vec, + /// Keys referenced by the credential but missing/empty on source. + pub cipher_keys_missing_in_source: Vec, +} + +/// Collect unique `[Key]` placeholders from secret templates (empty book → all keys "missing"). +pub fn collect_cipher_keys_from_templates<'a, I>(templates: I) -> Vec +where + I: IntoIterator>, +{ + let empty = empty_cipher_book(); + let mut keys: Vec = Vec::new(); + for template in templates.into_iter().flatten() { + if !template.contains('[') { + continue; + } + let resolved = resolve_cipher_placeholders(template, &empty); + for key in resolved.missing_keys { + if !keys.iter().any(|k| k == &key) { + keys.push(key); + } + } + } + keys +} + +/// Copy missing cipher fragments from source vault to target vault. +/// Never overwrites a non-empty target fragment. +pub fn sync_cipher_keys_for_share( + source_workspace: &Path, + target_workspace: &Path, + keys: &[String], +) -> Result<(Vec, Vec, Vec), WebError> { + if keys.is_empty() { + return Ok((Vec::new(), Vec::new(), Vec::new())); + } + let source_book = load_cipher_book(source_workspace)?; + let mut target_book = load_cipher_book(target_workspace)?; + let mut copied = Vec::new(); + let mut skipped_existing = Vec::new(); + let mut missing_in_source = Vec::new(); + let mut changed = false; + + for key in keys { + let key = key.trim(); + if key.is_empty() { + continue; + } + let Some(value) = source_book + .fragments + .get(key) + .filter(|v| !v.is_empty()) + .cloned() + else { + missing_in_source.push(key.to_string()); + continue; + }; + if target_book + .fragments + .get(key) + .map(|v| !v.is_empty()) + .unwrap_or(false) + { + skipped_existing.push(key.to_string()); + continue; + } + target_book + .fragments + .insert(key.to_string(), value); + copied.push(key.to_string()); + changed = true; + } + + if changed { + let vault = ensure_vault_directories(target_workspace)?; + target_book.revision = target_book.revision.saturating_add(1); + target_book.updated_at = now_rfc3339(); + write_cipher_book_atomic(&vault, &target_book)?; + } + + Ok((copied, skipped_existing, missing_in_source)) +} + +fn merge_ai_shared_tags(mut tags: Vec, extra_tags: &[String]) -> Vec { + for t in extra_tags { + let t = t.trim(); + if !t.is_empty() && !tags.iter().any(|x| x == t) { + tags.push(t.to_string()); + } + } + if !tags.iter().any(|t| t == "ai-shared") { + tags.push("ai-shared".to_string()); + } + tags +} + +/// Persist share-link fields on an existing record (rewrite index + disk). +fn write_record_with_index( + workspace_root: &Path, + record: &VaultCredentialRecord, +) -> Result<(), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + write_record_to_disk(&vault, record)?; + index + .entries + .insert(record.id.clone(), index_entry_from_record(record)); + index.revision = index.revision.saturating_add(1).max(record.revision); + index.updated_at = record.updated_at.clone(); + write_index_atomic(&vault, &index)?; + Ok(()) +} + +/// Copy mutable credential fields from source onto an existing AI copy (keep id / share origin). +fn apply_source_fields_to_ai_copy( + target: &mut VaultCredentialRecord, + source: &VaultCredentialRecord, + folder_path_override: Option<&str>, + extra_tags: &[String], +) { + target.title = source.title.clone(); + target.url = source.url.clone(); + target.username = source.username.clone(); + target.password = source.password.clone(); + target.password_hint = source.password_hint.clone(); + target.email = source.email.clone(); + target.apikey = source.apikey.clone(); + target.token = source.token.clone(); + target.fields = source.fields.clone(); + target.notes_markdown = source.notes_markdown.clone(); + target.folder_path = folder_path_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| source.folder_path.clone()) + .or_else(|| target.folder_path.clone()); + target.tags = merge_ai_shared_tags(source.tags.clone(), extra_tags); + // Keep AI-side login session always; seed playbook only if missing. + if target.login_playbook.is_none() { + target.login_playbook = source.login_playbook.clone().or_else(|| { + Some(VaultLoginPlaybook::default_for_credential( + source.email.as_deref(), + source.username.as_deref(), + )) + }); + } + target.updated_at = now_rfc3339(); + target.revision = target.revision.saturating_add(1); +} + +/// Push source content to an existing AI copy by id (used by edit-sync and re-share). +pub fn sync_shared_ai_copy( + source_workspace: &Path, + source: &VaultCredentialRecord, + target_workspace: &Path, + target_id: &str, + source_actor_id: &str, + target_actor_id: &str, + folder_path_override: Option<&str>, + sync_cipher_keys: bool, +) -> Result { + let mut cipher_keys_copied = Vec::new(); + let mut cipher_keys_skipped_existing = Vec::new(); + let mut cipher_keys_missing_in_source = Vec::new(); + if sync_cipher_keys { + let keys = collect_cipher_keys_from_templates([ + source.password.as_deref(), + source.apikey.as_deref(), + source.token.as_deref(), + ]); + let (copied, skipped, missing) = + sync_cipher_keys_for_share(source_workspace, target_workspace, &keys)?; + cipher_keys_copied = copied; + cipher_keys_skipped_existing = skipped; + cipher_keys_missing_in_source = missing; + } + + let mut target = get_credential(target_workspace, target_id)?; + if target.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_share_target_inactive", + "AI 密码本副本已删除,请重新共享", + )); + } + let now = now_rfc3339(); + apply_source_fields_to_ai_copy(&mut target, source, folder_path_override, &[]); + let shared_at = source + .shared_to_ai + .as_ref() + .map(|l| l.shared_at.clone()) + .or_else(|| target.shared_from.as_ref().map(|o| o.shared_at.clone())) + .unwrap_or_else(|| now.clone()); + target.shared_from = Some(VaultShareFrom { + source_actor_id: source_actor_id.to_string(), + source_id: source.id.clone(), + shared_at: shared_at.clone(), + }); + write_record_with_index(target_workspace, &target)?; + + let mut source_updated = source.clone(); + source_updated.shared_to_ai = Some(VaultShareToAi { + target_actor_id: target_actor_id.to_string(), + target_id: target.id.clone(), + shared_at, + last_synced_at: Some(now), + }); + source_updated.updated_at = now_rfc3339(); + // Do not bump source revision solely for link metadata if already written by update — + // callers that just updated will rewrite; bump lightly so index carries link. + source_updated.revision = source_updated.revision.saturating_add(1); + write_record_with_index(source_workspace, &source_updated)?; + + Ok(ShareCredentialOutcome { + item: target, + source: source_updated, + updated_existing: true, + cipher_keys_copied, + cipher_keys_skipped_existing, + cipher_keys_missing_in_source, + }) +} + +/// Copy a credential (including secret templates) into another workspace vault. +/// Secrets are re-written as-is (cipher `[Key]` templates preserved). +/// When `sync_cipher_keys` is true, missing target cipher fragments are copied +/// from the source book (existing non-empty target keys are never overwritten). +/// If source already has `shared_to_ai` and the target still exists, **updates** that copy. +/// Tags get `ai-shared` if missing. Optional `folder_path_override` (e.g. `from-user`). +pub fn share_credential_to_workspace( + source_workspace: &Path, + target_workspace: &Path, + id: &str, + folder_path_override: Option<&str>, + extra_tags: &[String], + sync_cipher_keys: bool, + source_actor_id: &str, + target_actor_id: &str, +) -> Result { + let source = get_credential(source_workspace, id)?; + if source.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_share_inactive", + "只能共享在用条目到 AI 密码本", + )); + } + + // Re-share / sync existing linked copy when still present. + if let Some(link) = source.shared_to_ai.clone() { + if !link.target_id.is_empty() + && get_credential(target_workspace, &link.target_id).is_ok() + { + return sync_shared_ai_copy( + source_workspace, + &source, + target_workspace, + &link.target_id, + source_actor_id, + target_actor_id, + folder_path_override, + sync_cipher_keys, + ); + } + } + + let mut cipher_keys_copied = Vec::new(); + let mut cipher_keys_skipped_existing = Vec::new(); + let mut cipher_keys_missing_in_source = Vec::new(); + if sync_cipher_keys { + let keys = collect_cipher_keys_from_templates([ + source.password.as_deref(), + source.apikey.as_deref(), + source.token.as_deref(), + ]); + let (copied, skipped, missing) = + sync_cipher_keys_for_share(source_workspace, target_workspace, &keys)?; + cipher_keys_copied = copied; + cipher_keys_skipped_existing = skipped; + cipher_keys_missing_in_source = missing; + } + + let tags = merge_ai_shared_tags(source.tags.clone(), extra_tags); + let folder = folder_path_override + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| source.folder_path.clone()); + let mut item = create_credential( + target_workspace, + VaultCreateInput { + title: source.title.clone(), + url: source.url.clone(), + username: source.username.clone(), + password: source.password.clone(), + password_hint: source.password_hint.clone(), + email: source.email.clone(), + apikey: source.apikey.clone(), + token: source.token.clone(), + folder_path: folder, + tags, + fields: source.fields.clone(), + notes_markdown: source.notes_markdown.clone(), + }, + )?; + let now = now_rfc3339(); + item.shared_from = Some(VaultShareFrom { + source_actor_id: source_actor_id.to_string(), + source_id: source.id.clone(), + shared_at: now.clone(), + }); + item.login_playbook = Some(VaultLoginPlaybook::default_for_credential( + item.email.as_deref(), + item.username.as_deref(), + )); + item.login_session = None; + write_record_with_index(target_workspace, &item)?; + + let mut source_updated = source; + source_updated.shared_to_ai = Some(VaultShareToAi { + target_actor_id: target_actor_id.to_string(), + target_id: item.id.clone(), + shared_at: now.clone(), + last_synced_at: Some(now), + }); + source_updated.revision = source_updated.revision.saturating_add(1); + source_updated.updated_at = now_rfc3339(); + write_record_with_index(source_workspace, &source_updated)?; + + Ok(ShareCredentialOutcome { + item, + source: source_updated, + updated_existing: false, + cipher_keys_copied, + cipher_keys_skipped_existing, + cipher_keys_missing_in_source, + }) +} + +/// After user vault update: if linked to AI copy, push fields + optional cipher keys. +pub fn maybe_sync_after_source_update( + source_workspace: &Path, + source: &VaultCredentialRecord, + target_workspace: &Path, + source_actor_id: &str, + sync_cipher_keys: bool, +) -> Result, WebError> { + let Some(link) = source.shared_to_ai.as_ref() else { + return Ok(None); + }; + if link.target_id.is_empty() { + return Ok(None); + } + match sync_shared_ai_copy( + source_workspace, + source, + target_workspace, + &link.target_id, + source_actor_id, + &link.target_actor_id, + None, + sync_cipher_keys, + ) { + Ok(outcome) => Ok(Some(outcome)), + Err(err) if err.code() == "vault_item_not_found" || err.code() == "vault_share_target_inactive" => { + Ok(None) + } + Err(err) => Err(err), + } +} + +/// Outcome of revoking a user→AI share link. +#[derive(Debug, Clone)] +pub struct UnshareCredentialOutcome { + /// Source credential with `shared_to_ai` cleared. + pub source: VaultCredentialRecord, + /// Target AI copy id that was soft-deleted (if any). + pub removed_target_id: Option, + /// Target actor id from the former link (if any). + #[allow(dead_code)] // surfaced to HTTP handlers / future callers + pub target_actor_id: Option, + /// True when the AI copy was soft-deleted; false if already missing/inactive. + pub target_deleted: bool, + /// True when source had no share link (idempotent no-op beyond re-read). + pub was_shared: bool, +} + +/// Revoke share-to-AI: clear source `shared_to_ai`, soft-delete the AI copy. +/// Cipher-book keys on the AI workspace are left intact (may be shared by other entries). +/// Idempotent: if already unshared, returns the source as-is with `was_shared=false`. +/// If `target_workspace` does not exist or the AI copy is missing, still clears the source link. +pub fn unshare_credential_from_ai( + source_workspace: &Path, + target_workspace: &Path, + id: &str, +) -> Result { + let source = get_credential(source_workspace, id)?; + let Some(link) = source.shared_to_ai.clone() else { + return Ok(UnshareCredentialOutcome { + source, + removed_target_id: None, + target_actor_id: None, + target_deleted: false, + was_shared: false, + }); + }; + + let mut target_deleted = false; + let removed_target_id = if link.target_id.is_empty() { + None + } else if !target_workspace.exists() { + // AI workspace not provisioned — clear link only. + Some(link.target_id.clone()) + } else { + match get_credential(target_workspace, &link.target_id) { + Ok(target) if target.status == VaultItemStatus::Active => { + let _ = soft_delete_credential(target_workspace, &link.target_id)?; + target_deleted = true; + Some(link.target_id.clone()) + } + Ok(_) => { + // Already deleted/inactive — still clear source link. + Some(link.target_id.clone()) + } + Err(err) + if err.code() == "vault_item_not_found" + || err.code() == "vault_share_target_inactive" => + { + // Target gone; still clear source link. + Some(link.target_id.clone()) + } + Err(err) => return Err(err), + } + }; + + let mut source_updated = source; + source_updated.shared_to_ai = None; + source_updated.updated_at = now_rfc3339(); + source_updated.revision = source_updated.revision.saturating_add(1); + write_record_with_index(source_workspace, &source_updated)?; + + Ok(UnshareCredentialOutcome { + source: source_updated, + removed_target_id, + target_actor_id: Some(link.target_actor_id), + target_deleted, + was_shared: true, + }) +} + +pub fn create_credential( + workspace_root: &Path, + input: VaultCreateInput, +) -> Result { + let title = input.title.trim().to_string(); + if title.is_empty() { + return Err(WebError::bad_request_code( + "vault_title_required", + "title 不能为空", + )); + } + if let Some(password) = input.password.as_deref() { + if is_mask_sentinel(password) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入 password", + )); + } + } + if let Some(apikey) = input.apikey.as_deref() { + if is_mask_sentinel(apikey) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入 apikey", + )); + } + } + if let Some(token) = input.token.as_deref() { + if is_mask_sentinel(token) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入 token", + )); + } + } + validate_fields(&input.fields)?; + validate_hint_not_secret(input.password_hint.as_deref(), input.password.as_deref())?; + + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let id = new_credential_id(); + let now = now_rfc3339(); + let revision = index.revision.saturating_add(1); + let record = VaultCredentialRecord { + id: id.clone(), + title, + url: input.url.filter(|v| !v.trim().is_empty()), + username: input.username.filter(|v| !v.trim().is_empty()), + password: input.password.filter(|v| !v.is_empty()), + password_hint: input.password_hint.filter(|v| !v.trim().is_empty()), + email: input.email.filter(|v| !v.trim().is_empty()), + apikey: input.apikey.filter(|v| !v.is_empty()), + token: input.token.filter(|v| !v.is_empty()), + folder_path: normalize_folder_path(input.folder_path.as_deref()), + tags: input.tags, + fields: input.fields, + attachments: Vec::new(), + notes_markdown: if input.notes_markdown.trim().is_empty() { + "## Notes\n\n".into() + } else { + input.notes_markdown + }, + status: VaultItemStatus::Active, + created_at: now.clone(), + updated_at: now.clone(), + relative_path: entry_rel_path(&id, VaultItemStatus::Active), + revision, + shared_to_ai: None, + shared_from: None, + login_playbook: None, + login_session: None, + }; + write_record_to_disk(&vault, &record)?; + index.entries.insert(id, index_entry_from_record(&record)); + index.revision = revision; + index.updated_at = now; + write_index_atomic(&vault, &index)?; + Ok(record) +} + +fn apply_secret_patch(current: Option, patch: &SecretPatch) -> Result, WebError> { + match patch { + SecretPatch::Omit => Ok(current), + SecretPatch::Clear => Ok(None), + SecretPatch::Set(value) => { + if is_mask_sentinel(value) { + return Err(WebError::bad_request_code( + "vault_mask_sentinel_rejected", + "不能将掩码哨兵写入密钥字段", + )); + } + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value.clone())) + } + } + } +} + +pub fn update_credential( + workspace_root: &Path, + id: &str, + input: VaultUpdateInput, +) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let mut record = read_record_from_disk(&vault, id, &index)?; + if record.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_item_not_active", + "只能编辑活跃条目;请先恢复", + )); + } + if let Some(base) = input.base_revision { + if base != record.revision { + return Err(WebError::new( + StatusCode::CONFLICT, + "vault_revision_conflict", + "密码箱条目已被其他操作更新,请刷新后重试", + )); + } + } + + if let Some(title) = input.title { + let title = title.trim().to_string(); + if title.is_empty() { + return Err(WebError::bad_request_code( + "vault_title_required", + "title 不能为空", + )); + } + record.title = title; + } + if let Some(url) = input.url { + record.url = url.filter(|v| !v.trim().is_empty()); + } + if let Some(username) = input.username { + record.username = username.filter(|v| !v.trim().is_empty()); + } + if let Some(email) = input.email { + record.email = email.filter(|v| !v.trim().is_empty()); + } + if let Some(hint) = input.password_hint { + record.password_hint = hint.filter(|v| !v.trim().is_empty()); + } + if let Some(folder) = input.folder_path { + record.folder_path = normalize_folder_path(folder.as_deref()); + } + if let Some(tags) = input.tags { + record.tags = tags; + } + if let Some(fields) = input.fields { + validate_fields(&fields)?; + record.fields = fields; + } + if let Some(notes) = input.notes_markdown { + record.notes_markdown = notes; + } + record.password = apply_secret_patch(record.password, &input.password)?; + record.apikey = apply_secret_patch(record.apikey, &input.apikey)?; + record.token = apply_secret_patch(record.token, &input.token)?; + validate_hint_not_secret(record.password_hint.as_deref(), record.password.as_deref())?; + + let now = now_rfc3339(); + record.updated_at = now.clone(); + record.revision = record.revision.saturating_add(1); + write_record_to_disk(&vault, &record)?; + index + .entries + .insert(record.id.clone(), index_entry_from_record(&record)); + index.revision = index.revision.saturating_add(1).max(record.revision); + index.updated_at = now; + write_index_atomic(&vault, &index)?; + Ok(record) +} + +fn move_path(from: &Path, to: &Path) -> Result<(), WebError> { + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "vault_move_failed", + format!("无法创建目标目录: {error}"), + ) + })?; + } + fs::rename(from, to).map_err(|error| { + WebError::bad_request_code( + "vault_move_failed", + format!("无法移动密码箱文件: {error}"), + ) + }) +} + +pub fn soft_delete_credential( + workspace_root: &Path, + id: &str, +) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let mut record = read_record_from_disk(&vault, id, &index)?; + if record.status == VaultItemStatus::Deleted { + return Ok(record); + } + let old_path = abs_from_vault_rel(&vault, &record.relative_path)?; + let new_rel = entry_rel_path(id, VaultItemStatus::Deleted); + let new_path = abs_from_vault_rel(&vault, &new_rel)?; + move_path(&old_path, &new_path)?; + // move attachments dir if present + let active_att = vault.join("attachments").join(id); + let trash_att = vault.join("trash").join("attachments").join(id); + if active_att.exists() { + let _ = move_path(&active_att, &trash_att); + } + for att in &mut record.attachments { + if att.relative_path.starts_with("attachments/") { + att.relative_path = format!("trash/{}", att.relative_path); + } + } + record.status = VaultItemStatus::Deleted; + record.relative_path = new_rel; + record.updated_at = now_rfc3339(); + record.revision = record.revision.saturating_add(1); + write_record_to_disk(&vault, &record)?; + index + .entries + .insert(record.id.clone(), index_entry_from_record(&record)); + index.revision = index.revision.saturating_add(1); + index.updated_at = record.updated_at.clone(); + write_index_atomic(&vault, &index)?; + Ok(record) +} + +pub fn restore_credential( + workspace_root: &Path, + id: &str, +) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let mut record = read_record_from_disk(&vault, id, &index)?; + if record.status == VaultItemStatus::Active { + return Ok(record); + } + let old_path = abs_from_vault_rel(&vault, &record.relative_path)?; + let new_rel = entry_rel_path(id, VaultItemStatus::Active); + let new_path = abs_from_vault_rel(&vault, &new_rel)?; + move_path(&old_path, &new_path)?; + let trash_att = vault.join("trash").join("attachments").join(id); + let active_att = vault.join("attachments").join(id); + if trash_att.exists() { + let _ = move_path(&trash_att, &active_att); + } + for att in &mut record.attachments { + if let Some(rest) = att.relative_path.strip_prefix("trash/") { + att.relative_path = rest.to_string(); + } + } + record.status = VaultItemStatus::Active; + record.relative_path = new_rel; + record.updated_at = now_rfc3339(); + record.revision = record.revision.saturating_add(1); + write_record_to_disk(&vault, &record)?; + index + .entries + .insert(record.id.clone(), index_entry_from_record(&record)); + index.revision = index.revision.saturating_add(1); + index.updated_at = record.updated_at.clone(); + write_index_atomic(&vault, &index)?; + Ok(record) +} + +pub fn purge_credential(workspace_root: &Path, id: &str) -> Result<(), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + let entry = index.entries.get(id).ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "vault_item_not_found", + format!("密码箱条目不存在: {id}"), + ) + })?; + if entry.status != VaultItemStatus::Deleted { + return Err(WebError::bad_request_code( + "vault_purge_requires_deleted", + "只能永久删除回收站中的条目;请先 soft-delete", + )); + } + let path = abs_from_vault_rel(&vault, &entry.relative_path)?; + if path.exists() { + fs::remove_file(&path).map_err(|error| { + WebError::bad_request_code( + "vault_purge_failed", + format!("无法删除密码箱条目: {error}"), + ) + })?; + } + let trash_att = vault.join("trash").join("attachments").join(id); + if trash_att.exists() { + let _ = fs::remove_dir_all(&trash_att); + } + index.entries.remove(id); + index.revision = index.revision.saturating_add(1); + index.updated_at = now_rfc3339(); + write_index_atomic(&vault, &index)?; + Ok(()) +} + +pub fn secret_field_state(value: Option<&str>) -> Value { + if secret_present(value) { + json!({ "state": "masked" }) + } else { + json!({ "state": "absent" }) + } +} + +/// Raw reveal without cipher expansion (tests / callers that need the template as stored). +#[allow(dead_code)] +pub fn secret_field_revealed(value: Option<&str>) -> Value { + match value { + Some(v) if !v.is_empty() => json!({ "state": "revealed", "value": v }), + _ => json!({ "state": "absent" }), + } +} + +fn project_share_to_ai(link: &VaultShareToAi) -> Value { + json!({ + "targetActorId": link.target_actor_id, + "targetId": link.target_id, + "sharedAt": link.shared_at, + "lastSyncedAt": link.last_synced_at, + }) +} + +fn project_share_from(origin: &VaultShareFrom) -> Value { + json!({ + "sourceActorId": origin.source_actor_id, + "sourceId": origin.source_id, + "sharedAt": origin.shared_at, + }) +} + +pub fn project_item_l0(record: &VaultCredentialRecord) -> Value { + let mut item = json!({ + "schema": "mnote.vault.item.v1", + "id": record.id, + "title": record.title, + "url": record.url, + "username": record.username, + "email": record.email, + "passwordHint": record.password_hint, + "folderPath": record.folder_path, + "password": secret_field_state(record.password.as_deref()), + "apikey": secret_field_state(record.apikey.as_deref()), + "token": secret_field_state(record.token.as_deref()), + "tags": record.tags, + "fields": record.fields, + "attachments": record.attachments, + "notesMarkdown": record.notes_markdown, + "status": record.status.as_str(), + "createdAt": record.created_at, + "updatedAt": record.updated_at, + "relativePath": record.relative_path, + "revision": record.revision, + "isSharedToAi": record.shared_to_ai.is_some(), + "isAiSharedCopy": record.shared_from.is_some() + || record.tags.iter().any(|t| t == "ai-shared"), + "hasLoginSession": record + .login_session + .as_ref() + .and_then(|s| s.cookie_header.as_ref()) + .map(|c| !c.trim().is_empty()) + .unwrap_or(false), + "sessionExpiresAt": record + .login_session + .as_ref() + .and_then(|s| s.expires_at.clone()), + "lastLoginAt": record + .login_session + .as_ref() + .and_then(|s| s.last_login_at.clone()), + }); + if let Some(link) = &record.shared_to_ai { + item["sharedToAi"] = project_share_to_ai(link); + } + if let Some(origin) = &record.shared_from { + item["sharedFrom"] = project_share_from(origin); + } + if let Some(pb) = &record.login_playbook { + item["loginPlaybook"] = json!({ + "mode": pb.mode, + "preferredAccount": pb.preferred_account, + "apiPath": pb.api_path, + "accountSelector": pb.account_selector, + "passwordSelector": pb.password_selector, + "submitSelector": pb.submit_selector, + "successUrlContains": pb.success_url_contains, + "humanNote": pb.human_note, + }); + } + // Never project cookieHeader in L0. + if let Some(sess) = &record.login_session { + item["loginSession"] = json!({ + "hasCookie": sess.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false), + "expiresAt": sess.expires_at, + "lastLoginAt": sess.last_login_at, + "source": sess.source, + }); + } + item +} + +pub fn project_list_entry(entry: &VaultIndexEntry) -> Value { + let mut item = json!({ + "id": entry.id, + "title": entry.title, + "url": entry.url, + "username": entry.username, + "email": entry.email, + "passwordHint": entry.password_hint, + "folderPath": entry.folder_path, + "tags": entry.tags, + "hasPassword": entry.has_password, + "hasApikey": entry.has_apikey, + "hasToken": entry.has_token, + "attachmentCount": entry.attachment_count, + "relativePath": entry.relative_path, + "updatedAt": entry.updated_at, + "status": entry.status.as_str(), + "revision": entry.revision, + "isSharedToAi": entry.shared_to_ai.is_some(), + "isAiSharedCopy": entry.shared_from.is_some() + || entry.tags.iter().any(|t| t == "ai-shared"), + "hasLoginSession": entry.has_login_session, + "sessionExpiresAt": entry.session_expires_at, + "loginMode": entry.login_mode, + }); + if let Some(link) = &entry.shared_to_ai { + item["sharedToAi"] = project_share_to_ai(link); + } + if let Some(origin) = &entry.shared_from { + item["sharedFrom"] = project_share_from(origin); + } + item +} + +/// Persist login session on a credential (AI vault session write-back). +pub fn put_login_session( + workspace_root: &Path, + id: &str, + session: VaultLoginSession, +) -> Result { + let mut record = get_credential(workspace_root, id)?; + if record.status != VaultItemStatus::Active { + return Err(WebError::bad_request_code( + "vault_session_inactive", + "只能给在用条目写入登录态", + )); + } + record.login_session = Some(session); + record.updated_at = now_rfc3339(); + record.revision = record.revision.saturating_add(1); + write_record_with_index(workspace_root, &record)?; + Ok(record) +} + +pub fn put_login_playbook( + workspace_root: &Path, + id: &str, + playbook: VaultLoginPlaybook, +) -> Result { + let mut record = get_credential(workspace_root, id)?; + record.login_playbook = Some(playbook); + record.updated_at = now_rfc3339(); + record.revision = record.revision.saturating_add(1); + write_record_with_index(workspace_root, &record)?; + Ok(record) +} + +/// Whether stored session should be treated as still valid (not expired). +pub fn login_session_is_fresh(session: &VaultLoginSession) -> bool { + let Some(cookie) = session.cookie_header.as_ref().map(|s| s.trim()) else { + return false; + }; + if cookie.is_empty() { + return false; + } + let Some(exp) = session.expires_at.as_ref().map(|s| s.trim()) else { + // No expiry: treat as fresh (TTL applied at write time normally). + return true; + }; + if exp.is_empty() { + return true; + } + // Lexicographic compare works for RFC3339 UTC timestamps (Z suffix). + let now = now_rfc3339(); + exp > now.as_str() +} + +/// Project reveal payload; expands cipher-book `[Key]` placeholders in the secret. +pub fn project_secret_revealed( + workspace_root: &Path, + template: Option<&str>, +) -> Result { + match resolve_secret_with_cipher_book(workspace_root, template)? { + None => Ok(json!({ "state": "absent" })), + Some(resolved) => { + let mut obj = json!({ + "state": "revealed", + "value": resolved.value, + }); + if let Some(t) = resolved.template { + obj["template"] = json!(t); + } + if !resolved.used_keys.is_empty() { + obj["usedCipherKeys"] = json!(resolved.used_keys); + } + if !resolved.missing_keys.is_empty() { + obj["missingCipherKeys"] = json!(resolved.missing_keys); + } + Ok(obj) + } + } +} + +pub fn reindex(workspace_root: &Path) -> Result { + let vault = ensure_vault_directories(workspace_root)?; + rebuild_index(&vault) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_root() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("mnote-vault-store-{nanos}")); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn create_list_get_masks_password() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "GitHub".into(), + url: Some("https://github.com".into()), + username: Some("alice".into()), + password: Some("Li@[A]s3cret".into()), + password_hint: Some("Li@[A]***".into()), + tags: vec!["work".into()], + ..Default::default() + }, + ) + .expect("create"); + assert!(created.password.as_deref() == Some("Li@[A]s3cret")); + + let (_index, items) = list_credentials(&root, VaultItemStatus::Active).expect("list"); + assert_eq!(items.len(), 1); + assert_eq!(items[0].title, "GitHub"); + assert!(items[0].has_password); + let projected = project_list_entry(&items[0]); + let body = projected.to_string(); + assert!(!body.contains("Li@[A]s3cret")); + assert!(projected["hasPassword"].as_bool().unwrap()); + + let got = get_credential(&root, &created.id).expect("get"); + let item = project_item_l0(&got); + assert_eq!(item["password"]["state"], "masked"); + assert!(item.get("password").unwrap().get("value").is_none()); + assert!(!item.to_string().contains("Li@[A]s3cret")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn update_title_only_keeps_password() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "Old".into(), + password: Some("keep-me-secret".into()), + ..Default::default() + }, + ) + .unwrap(); + let updated = update_credential( + &root, + &created.id, + VaultUpdateInput { + title: Some("New".into()), + password: SecretPatch::Omit, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.title, "New"); + assert_eq!(updated.password.as_deref(), Some("keep-me-secret")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn mask_sentinel_rejected() { + let root = temp_root(); + let err = create_credential( + &root, + VaultCreateInput { + title: "X".into(), + password: Some("••••••••".into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "vault_mask_sentinel_rejected"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn hint_equals_password_rejected() { + let root = temp_root(); + let err = create_credential( + &root, + VaultCreateInput { + title: "X".into(), + password: Some("same".into()), + password_hint: Some("same".into()), + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "vault_hint_equals_secret"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn fields_secret_key_forbidden() { + let root = temp_root(); + let mut fields = BTreeMap::new(); + fields.insert("otp_secret".into(), "x".into()); + let err = create_credential( + &root, + VaultCreateInput { + title: "X".into(), + fields, + ..Default::default() + }, + ) + .unwrap_err(); + assert_eq!(err.code(), "vault_fields_secret_forbidden"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn soft_delete_restore_purge() { + let root = temp_root(); + let created = create_credential( + &root, + VaultCreateInput { + title: "Del".into(), + password: Some("p".into()), + ..Default::default() + }, + ) + .unwrap(); + soft_delete_credential(&root, &created.id).unwrap(); + let (_i, active) = list_credentials(&root, VaultItemStatus::Active).unwrap(); + assert!(active.is_empty()); + let (_i, deleted) = list_credentials(&root, VaultItemStatus::Deleted).unwrap(); + assert_eq!(deleted.len(), 1); + assert!(vault_root(&root) + .join("trash") + .join("entries") + .join(format!("{}.md", created.id)) + .exists()); + restore_credential(&root, &created.id).unwrap(); + let (_i, active) = list_credentials(&root, VaultItemStatus::Active).unwrap(); + assert_eq!(active.len(), 1); + soft_delete_credential(&root, &created.id).unwrap(); + purge_credential(&root, &created.id).unwrap(); + let (_i, deleted) = list_credentials(&root, VaultItemStatus::Deleted).unwrap(); + assert!(deleted.is_empty()); + assert!(!vault_root(&root) + .join("trash") + .join("entries") + .join(format!("{}.md", created.id)) + .exists()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn parse_error_log_path_has_no_password_in_rebuild() { + let root = temp_root(); + let vault = ensure_vault_directories(&root).unwrap(); + fs::write( + vault.join("entries").join("bad.md"), + "not a valid entry without fm", + ) + .unwrap(); + // rebuild should not panic; bad entry skipped + let index = rebuild_index(&vault).unwrap(); + assert!(!index.entries.contains_key("bad")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn audit_never_contains_secret_value_key() { + let root = temp_root(); + append_vault_audit( + &root, + "reveal", + "user_x", + "cred_1", + Some("password"), + Some("req_1"), + true, + ) + .unwrap(); + let content = fs::read_to_string(vault_root(&root).join("audit.jsonl")).unwrap(); + assert!(content.contains("\"action\":\"reveal\"")); + assert!(!content.contains("\"value\"")); + assert!(!content.contains("password:")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn cipher_book_expands_brackets_on_resolve() { + let root = temp_root(); + set_cipher_fragment(&root, "A", "Li@").unwrap(); + set_cipher_fragment(&root, "B", "99").unwrap(); + let book = load_cipher_book(&root).unwrap(); + let resolved = resolve_cipher_placeholders("Li@[A]s3cret[B]", &book); + assert_eq!(resolved.value, "Li@Li@s3cret99"); + assert_eq!(resolved.used_keys, vec!["A".to_string(), "B".to_string()]); + assert!(resolved.missing_keys.is_empty()); + assert_eq!( + resolved.template.as_deref(), + Some("Li@[A]s3cret[B]") + ); + + let missing = resolve_cipher_placeholders("pre[Z]post", &book); + assert_eq!(missing.value, "pre[Z]post"); + assert_eq!(missing.missing_keys, vec!["Z".to_string()]); + + let projected = project_secret_revealed(&root, Some("x[A]y")).unwrap(); + assert_eq!(projected["value"], "xLi@y"); + assert_eq!(projected["template"], "x[A]y"); + assert!(!projected.to_string().contains("\"Li@\"") || projected["value"] == "xLi@y"); + // L0 cipher book never exposes fragment plaintext + let l0 = project_cipher_book_l0(&book); + assert!(!l0.to_string().contains("Li@")); + assert_eq!(l0["keys"].as_array().unwrap().len(), 2); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn same_url_multiple_accounts_allowed() { + let root = temp_root(); + let url = "https://github.com"; + let a = create_credential( + &root, + VaultCreateInput { + title: "GH work".into(), + url: Some(url.into()), + username: Some("work@corp".into()), + password: Some("p1".into()), + folder_path: Some("work/github".into()), + ..Default::default() + }, + ) + .unwrap(); + let b = create_credential( + &root, + VaultCreateInput { + title: "GH personal".into(), + url: Some(url.into()), + username: Some("me@home".into()), + password: Some("p2".into()), + folder_path: Some("personal/github".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(a.id, b.id); + let (_i, items) = list_credentials(&root, VaultItemStatus::Active).unwrap(); + assert_eq!(items.len(), 2); + assert!(items.iter().all(|e| e.url.as_deref() == Some(url))); + assert_eq!(a.folder_path.as_deref(), Some("work/github")); + assert_eq!(b.folder_path.as_deref(), Some("personal/github")); + let got = get_credential(&root, &a.id).unwrap(); + let item = project_item_l0(&got); + assert_eq!(item["folderPath"], "work/github"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn share_credential_copies_secrets_and_tags() { + let src = temp_root(); + let dst = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "OpenAI".into(), + url: Some("https://platform.openai.com".into()), + username: Some("bot".into()), + password: Some("Li@[A]s3cret".into()), + apikey: Some("sk-test".into()), + folder_path: Some("ai/keys".into()), + tags: vec!["prod".into()], + ..Default::default() + }, + ) + .unwrap(); + let outcome = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + let shared = outcome.item; + assert_ne!(shared.id, created.id); + assert_eq!(shared.password.as_deref(), Some("Li@[A]s3cret")); + assert_eq!(shared.apikey.as_deref(), Some("sk-test")); + assert_eq!(shared.folder_path.as_deref(), Some("from-user")); + assert!(shared.tags.iter().any(|t| t == "ai-shared")); + assert!(shared.tags.iter().any(|t| t == "prod")); + assert!(shared.shared_from.is_some()); + assert_eq!( + shared.shared_from.as_ref().unwrap().source_id, + created.id + ); + // source now linked + let still = get_credential(&src, &created.id).unwrap(); + assert!(still.shared_to_ai.is_some()); + assert_eq!( + still.shared_to_ai.as_ref().unwrap().target_id, + shared.id + ); + // [A] present in template but missing on source book → reported + assert!(outcome + .cipher_keys_missing_in_source + .iter() + .any(|k| k == "A")); + let (_i, items) = list_credentials(&dst, VaultItemStatus::Active).unwrap(); + assert_eq!(items.len(), 1); + // re-share updates existing, no second copy + let outcome2 = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(outcome2.updated_existing); + assert_eq!(outcome2.item.id, shared.id); + let (_i, items2) = list_credentials(&dst, VaultItemStatus::Active).unwrap(); + assert_eq!(items2.len(), 1); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + } + + #[test] + fn share_credential_syncs_missing_cipher_keys_without_overwrite() { + let src = temp_root(); + let dst = temp_root(); + set_cipher_fragment(&src, "A", "from-source").unwrap(); + set_cipher_fragment(&src, "B", "source-b").unwrap(); + // target already has B with different value — must not overwrite + set_cipher_fragment(&dst, "B", "target-b").unwrap(); + let created = create_credential( + &src, + VaultCreateInput { + title: "With cipher".into(), + password: Some("Li@[A]x[B]".into()), + ..Default::default() + }, + ) + .unwrap(); + let outcome = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(outcome.cipher_keys_copied.iter().any(|k| k == "A")); + assert!(outcome.cipher_keys_skipped_existing.iter().any(|k| k == "B")); + assert!(outcome.cipher_keys_missing_in_source.is_empty()); + let dst_book = load_cipher_book(&dst).unwrap(); + assert_eq!(dst_book.fragments.get("A").map(String::as_str), Some("from-source")); + assert_eq!(dst_book.fragments.get("B").map(String::as_str), Some("target-b")); + // resolve on target should expand A, keep B as target value + let resolved = + resolve_secret_with_cipher_book(&dst, Some("Li@[A]x[B]")).unwrap().unwrap(); + assert_eq!(resolved.value, "Li@from-sourcextarget-b"); + // syncCipherKeys=false on a fresh source without link leaves target book empty of new keys + // (use new source without prior shared_to_ai — re-share would update existing) + let src2 = temp_root(); + let dst2 = temp_root(); + let created2 = create_credential( + &src2, + VaultCreateInput { + title: "No sync".into(), + password: Some("Li@[A]x".into()), + ..Default::default() + }, + ) + .unwrap(); + set_cipher_fragment(&src2, "A", "zzz").unwrap(); + let outcome2 = share_credential_to_workspace( + &src2, + &dst2, + &created2.id, + None, + &[], + false, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(outcome2.cipher_keys_copied.is_empty()); + assert!(load_cipher_book(&dst2).unwrap().fragments.is_empty()); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + let _ = fs::remove_dir_all(&src2); + let _ = fs::remove_dir_all(&dst2); + } + + #[test] + fn update_source_auto_syncs_ai_copy_fields() { + let src = temp_root(); + let dst = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "Before".into(), + password: Some("old-pass".into()), + username: Some("u1".into()), + ..Default::default() + }, + ) + .unwrap(); + let shared = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + let updated = update_credential( + &src, + &created.id, + VaultUpdateInput { + title: Some("After".into()), + password: SecretPatch::Set("new-pass".into()), + ..Default::default() + }, + ) + .unwrap(); + // link still present after update + assert!(updated.shared_to_ai.is_some()); + let sync = maybe_sync_after_source_update( + &src, + &updated, + &dst, + "user-a", + true, + ) + .unwrap() + .expect("should sync"); + assert_eq!(sync.item.title, "After"); + assert_eq!(sync.item.password.as_deref(), Some("new-pass")); + assert_eq!(sync.item.id, shared.item.id); + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + } + + #[test] + fn unshare_credential_clears_link_and_soft_deletes_ai_copy() { + let src = temp_root(); + let dst = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "Shared then revoke".into(), + password: Some("secret".into()), + ..Default::default() + }, + ) + .unwrap(); + let shared = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(get_credential(&src, &created.id) + .unwrap() + .shared_to_ai + .is_some()); + assert_eq!( + get_credential(&dst, &shared.item.id).unwrap().status, + VaultItemStatus::Active + ); + + let outcome = unshare_credential_from_ai(&src, &dst, &created.id).unwrap(); + assert!(outcome.was_shared); + assert!(outcome.target_deleted); + assert_eq!( + outcome.removed_target_id.as_deref(), + Some(shared.item.id.as_str()) + ); + assert!(outcome.source.shared_to_ai.is_none()); + let source_after = get_credential(&src, &created.id).unwrap(); + assert!(source_after.shared_to_ai.is_none()); + let target_after = get_credential(&dst, &shared.item.id).unwrap(); + assert_eq!(target_after.status, VaultItemStatus::Deleted); + + // Idempotent: second unshare is a no-op on link. + let again = unshare_credential_from_ai(&src, &dst, &created.id).unwrap(); + assert!(!again.was_shared); + assert!(!again.target_deleted); + assert!(again.source.shared_to_ai.is_none()); + + // After unshare, re-share creates a fresh AI copy (old one is deleted). + let reshare = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + true, + "user-a", + "mnote-e2e", + ) + .unwrap(); + assert!(!reshare.updated_existing); + assert_ne!(reshare.item.id, shared.item.id); + assert!(get_credential(&src, &created.id) + .unwrap() + .shared_to_ai + .is_some()); + + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + } + + #[test] + fn normalize_folder_path_strips_dots() { + assert_eq!( + normalize_folder_path(Some(" 个人//银行/../招商 ")).as_deref(), + Some("个人/银行/招商") + ); + assert_eq!(normalize_folder_path(Some("///")), None); + assert_eq!(normalize_folder_path(Some("")), None); + } +} diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 3134d9dc..db1d8f46 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -11,8 +11,9 @@ use crate::routes::gateway::default_workspace_name_for_context; use crate::routes::local_folder_source::{ ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot_with_reveal, - load_local_folder_file_tree_snapshot_with_reveal, load_local_folder_page_tree_scope_snapshot, - load_local_folder_page_tree_snapshot, resolve_local_markdown_page_aggregate, + load_local_folder_file_tree_snapshot_with_reveal, + load_local_folder_page_tree_scope_snapshot_with_reveal, + load_local_folder_page_tree_snapshot_with_reveal, resolve_local_markdown_page_aggregate, }; use crate::routes::query_support::execute_runtime_query_against_data; use crate::routes::snapshot_support::{ @@ -21,12 +22,12 @@ use crate::routes::snapshot_support::{ use crate::routes::tree::{collect_filetree_render_rows, collect_page_tree_render_rows}; use crate::ssr::pages::document::DocumentPage; use crate::tree_shell::filetree_renderer::{ - render_initial_filetree_html, FileTreeInitialRenderInput, + render_filetree_pending_shell_html, render_initial_filetree_html, FileTreeInitialRenderInput, }; use crate::tree_shell::page_renderer::{render_initial_page_tree_html, PageTreeInitialRenderInput}; use crate::workspace_shell::{ - apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html, - WorkspaceShellProjection, + apply_active_page, build_workspace_shell_projection, render_page_breadcrumb_html, + render_workspace_shell_sidebar_html, WorkspaceShellProjection, }; use axum::body::Body; use axum::extract::{Extension, Path, Query, State}; @@ -261,6 +262,7 @@ pub async fn document_page_shell( file_tree_scope, ); let workspace_name = workspace_projection.workspace_name.clone(); + let breadcrumb_html = render_page_breadcrumb_html(&workspace_projection, Some(&document_id)); let page_subtree_json = serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string()); let page_options_json = serde_json::to_string(&aggregate.layout.page_options) @@ -300,6 +302,10 @@ pub async fn document_page_shell( ); let pi_lab_loader_script = render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab); + let vault_nav_href = crate::routes::gateway::vault_nav_href_for_context( + primary_source_kind, + primary_root_uri, + ); let body_content = crate::ssr::render_view(leptos::view! { }); let hermes_settings_config_script = render_hermes_settings_config_script(); @@ -766,6 +774,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { let saving = false; const saveTitle = async () => { + if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') { + input.value = readLastSavedTitle(); + autosize(input); + setStatus(input, 'saved'); + return; + } const title = input.value.trim() || '无标题'; const currentTarget = resolveTitleTarget(input); autosize(input); @@ -820,6 +834,12 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { }; input.addEventListener('input', () => { + if (input.readOnly || input.getAttribute('data-document-user-readonly-mode') === 'true') { + input.value = readLastSavedTitle(); + autosize(input); + setStatus(input, 'saved'); + return; + } autosize(input); setStatus(input, (input.value.trim() || '无标题') === readLastSavedTitle() ? 'saved' : 'dirty'); }); @@ -3103,6 +3123,20 @@ pub async fn document_editor_adapter_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn vault_workbench_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/vault-workbench-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, runtime_asset_cache_control()) + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(browser_runtime_js_body(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn leptos_tiptap_manifest() -> Response { let manifest = json!({ "entryAssetPath": "mnote-leptos-tiptap-spike-island.js", @@ -3603,9 +3637,13 @@ pub(crate) fn render_local_sidebar_tree_html_scoped( .map(str::trim) .filter(|value| !value.is_empty()) { - load_local_folder_page_tree_scope_snapshot(root_uri, scope)? + load_local_folder_page_tree_scope_snapshot_with_reveal( + root_uri, + scope, + active_document_id, + )? } else { - load_local_folder_page_tree_snapshot(root_uri)? + load_local_folder_page_tree_snapshot_with_reveal(root_uri, active_document_id)? }; Ok(render_local_sidebar_tree_html_from_snapshot( &snapshot, @@ -3633,6 +3671,12 @@ pub(crate) fn render_local_file_tree_html( render_local_file_tree_html_scoped(root_uri, active_document_id, active_row_id, None) } +/// Fast path for home SSR: keep FileTree shell structure without scanning the folder. +/// Browser hydrates rows after first paint when `data-filetree-ssr="pending"`. +pub(crate) fn render_local_file_tree_pending_shell_html() -> String { + render_filetree_pending_shell_html() +} + pub(crate) fn render_local_file_tree_html_scoped( root_uri: &str, active_document_id: Option<&str>, @@ -5320,8 +5364,10 @@ mod tests { )); assert!(session_runtime.contains("new EventSource(url.toString())")); assert!(session_runtime.contains("localFolderEventRegistry")); - assert!(session_runtime - .contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')")); + assert!(session_runtime.contains("if (!response.ok) {")); + assert!(session_runtime.contains( + "if (session.sourceKind === 'local_folder' && (response.status === 404 || errorCode === 'local_markdown_not_found'))" + )); assert!(session_runtime.contains("shouldSuppressLocalFolderSelfChange")); assert!(session_runtime.contains("kind.includes('Modify(Name')")); assert!(session_runtime.contains("targetSession.views.size === 0")); @@ -5707,7 +5753,8 @@ mod tests { assert!(runtime.contains("legacyInlineContentToTiptap")); assert!(runtime.contains("legacyStylesToTiptapMarks")); assert!(runtime.contains("legacyMarkArrayToTiptapMarks")); - assert!(runtime.contains("firstNonEmptyText(block?.props?.sourcePath")); + assert!(runtime.contains("const sourcePath = firstNonEmptyText(")); + assert!(runtime.contains("block?.props?.sourcePath")); assert!(runtime.contains("marks.push({ type: 'bold' })")); assert!(runtime.contains("marks.push({ type: 'italic' })")); assert!(runtime.contains("marks.push({ type: 'underline' })")); diff --git a/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs b/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs index b7c39a38..0f0d131a 100644 --- a/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs +++ b/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs @@ -1365,8 +1365,9 @@ const AI_ADMIN_SCRIPT: &str = r#" } if (knowledgePanel) { var lightrag = effectivePayload && (effectivePayload.lightragProvider || effectivePayload.lightrag_provider) || {}; + // S5: hydrate read-only health + directory summary from /api/knowledge-rag/status knowledgePanel.innerHTML = - '
    ' + + '
    ' + '
    ' + '

    LightRAG

    ' + renderTag('唯一默认 provider', 'green') + renderTag('MNote facade', 'blue') + @@ -1377,7 +1378,11 @@ const AI_ADMIN_SCRIPT: &str = r#" renderKv('查询入口', 'mnote.knowledge_rag.query') + renderKv('引用回跳', 'citation / open-reference') + renderKv('Pi 接入方式', '只能通过 MNote knowledge facade 查询') + + renderKv('Health', '加载中…') + + renderKv('Pipeline', '加载中…') + + renderKv('Documents', '加载中…') + '
    ' + + '

    正在读取 /api/knowledge-rag/status…

    ' + '
    ' + '
    ' + '
    ' + @@ -1385,10 +1390,60 @@ const AI_ADMIN_SCRIPT: &str = r#" renderTag('复用 allowed roots', 'green') + renderTag('不引入第二套 RAG', 'orange') + '
    ' + '
    ' + - '

    资料 source/index/status 后续接 /api/knowledge-rag/*;目录授权继续来自 MNote allowed roots,不在 AI 管理页维护第二套目录。

    ' + + '

    目录授权继续来自 MNote allowed roots(directory_grants);本面板只读展示 LightRAG health / pipeline / documents 摘要,不维护第二套目录。

    ' + '
    ' + '
    ' + ''; + requestJson('/api/knowledge-rag/status', { method: 'GET' }).then(function (statusPayload) { + if (!knowledgePanel) return; + var health = statusPayload && statusPayload.health || {}; + var healthOk = health.ok === true || (health.health && health.health.ok === true); + var healthLabel = healthOk ? 'ok' : (health.message || health.code || 'unavailable'); + var pipeline = statusPayload && statusPayload.pipeline || {}; + var pipelineLabel = pipeline.ok === false + ? (pipeline.message || pipeline.code || 'error') + : (pipeline.status || pipeline.phase || pipeline.state || (pipeline.ok === true ? 'ok' : JSON.stringify(pipeline).slice(0, 80))); + var docs = statusPayload && statusPayload.documents || {}; + var docList = Array.isArray(docs.documents) ? docs.documents : []; + var groups = docs.rawStatusGroups || {}; + var groupKeys = Object.keys(groups || {}); + var groupSummary = groupKeys.length + ? groupKeys.map(function (k) { return k + '=' + groups[k]; }).join(', ') + : (docList.length + ' docs'); + var registry = statusPayload && statusPayload.registry || null; + var entryCount = registry && Array.isArray(registry.entries) ? registry.entries.length : 0; + var rootCount = registry && Array.isArray(registry.indexed_roots || registry.indexedRoots) + ? (registry.indexed_roots || registry.indexedRoots).length + : 0; + var provider = (statusPayload && statusPayload.provider) || lightrag.provider || 'lightrag'; + knowledgePanel.innerHTML = + '
    ' + + '
    ' + + '

    LightRAG

    ' + + renderTag(healthOk ? 'health ok' : 'health down', healthOk ? 'green' : 'orange') + + renderTag(String(provider), 'blue') + + '
    ' + + '
    ' + + '
    ' + + renderKv('Provider', provider) + + renderKv('Endpoint', (statusPayload && statusPayload.endpoint) || '-') + + renderKv('Health', healthLabel) + + renderKv('Pipeline', String(pipelineLabel)) + + renderKv('Documents', groupSummary) + + renderKv('Registry entries', String(entryCount)) + + renderKv('Indexed roots', String(rootCount)) + + renderKv('查询入口', 'mnote.knowledge_rag.query') + + '
    ' + + '

    只读摘要来自 /api/knowledge-rag/status;目录授权真相仍是 control-plane directory_grants / allowed roots。

    ' + + '
    ' + + '
    ' + + '
    '; + }).catch(function (err) { + if (!knowledgePanel) return; + var msg = err && err.message ? err.message : 'status unavailable'; + var summary = knowledgePanel.querySelector('[data-ai-admin-knowledge-summary]'); + if (summary) summary.textContent = 'LightRAG status 读取失败: ' + msg; + }); } if (healthGrid) { healthGrid.innerHTML = [ @@ -2747,10 +2802,14 @@ const AI_ADMIN_SCRIPT: &str = r#" collectSkillsFromDOM().forEach(function(skill) { var id = skill.id || skill.name; if (!id) return; + // S2: preserve source/risk/scopes so delete/save does not soft-break skills. body.skills[id] = { name: skill.name, enabled: skill.enabled, - description: skill.description || '' + description: skill.description || '', + source: skill.source || '', + riskLevel: skill.riskLevel || skill.risk_level || 'low', + requiredScopes: skill.requiredScopes || skill.required_scopes || [] }; }); } @@ -2768,7 +2827,10 @@ const AI_ADMIN_SCRIPT: &str = r#" networkPolicy: server.networkPolicy || 'deny-all', secretRefs: server.secretRefs || [], facadeOnly: server.facadeOnly !== false, - sandbox: true + sandbox: true, + description: server.description || '', + riskLevel: server.riskLevel || server.risk_level || 'medium', + requiredScopes: server.requiredScopes || server.required_scopes || [] }; }); } diff --git a/rust/crates/mnote-web/src/ssr/pages/document.rs b/rust/crates/mnote-web/src/ssr/pages/document.rs index 9a06c776..b35390fb 100644 --- a/rust/crates/mnote-web/src/ssr/pages/document.rs +++ b/rust/crates/mnote-web/src/ssr/pages/document.rs @@ -97,8 +97,18 @@ pub(super) fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView { data-pane-role={pane_role_attr_three} data-title-endpoint="/api/documents/title" rows="1" + readonly + data-document-user-readonly-mode="true" >{model.title.clone()} +
    {model.title.clone()} @@ -161,6 +171,9 @@ pub fn DocumentPage( /// workspace shell 侧栏 sections HTML(可选) #[prop(optional)] workspace_sidebar_html: Option, + /// 顶栏页面祖先链 HTML(可选) + #[prop(optional)] + breadcrumb_html: Option, /// Page Aggregate 子树 JSON(可选) #[prop(optional)] page_subtree_json: Option, @@ -194,6 +207,9 @@ pub fn DocumentPage( /// 是否启用树实时流 #[prop(optional, default = true)] enable_tree_live: bool, + /// 密码箱导航 SSR href(含 local_folder 上下文) + #[prop(optional)] + vault_nav_href: Option, ) -> impl IntoView { let has_page_subtree = page_subtree_json .as_deref() @@ -293,7 +309,7 @@ pub fn DocumentPage( hide_title_header: secondary_hide_title_header, }; view! { - +
    , + /// 顶栏页面祖先链 HTML(可选) + #[prop(optional)] + breadcrumb_html: Option, /// 当前选中的页面 id(可选) #[prop(optional)] active_page_id: Option, @@ -36,6 +39,9 @@ pub fn HomePage( /// 是否启用树实时流 #[prop(optional, default = true)] enable_tree_live: bool, + /// 密码箱导航 SSR href(含 local_folder 上下文) + #[prop(optional)] + vault_nav_href: Option, ) -> impl IntoView { let active_page_id = active_page_id.unwrap_or_default(); let active_page_title = active_page_title @@ -54,7 +60,7 @@ pub fn HomePage( .map(|workspace_id| format!("/documents/{active_page_id}?workspaceId={workspace_id}")) .unwrap_or_else(|| format!("/documents/{active_page_id}")); view! { - + {move || if has_active_page { view! {
    diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 8fcaa222..fed811e1 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -62,15 +62,25 @@ pub fn PageLayout( /// 顶栏当前页面标题(可选) #[prop(optional)] topbar_title: Option, + /// 顶栏页面祖先链 HTML(可选) + #[prop(optional)] + breadcrumb_html: Option, /// 是否显示管理员授权能力 #[prop(optional)] show_admin_access_policy: bool, /// 是否启用树实时流 #[prop(optional, default = true)] enable_tree_live: bool, + /// 密码箱导航 SSR href(含 sourceKind/rootUri,避免首击全页丢失上下文) + #[prop(optional)] + vault_nav_href: Option, ) -> impl IntoView { let _ = show_admin_access_policy; let sidebar_tree_html = sidebar_tree_html.unwrap_or_default(); + let vault_nav_href = vault_nav_href + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "/vault".to_string()); let ws_name = workspace_name .map(|value| value.trim().to_string()) @@ -80,6 +90,15 @@ pub fn PageLayout( .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "个人".to_string()); + let breadcrumb_html = breadcrumb_html + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + format!( + r#"{}"#, + crate::routes::web_shell::escape_html(&topbar_title), + ) + }); let sidebar_sections_html = workspace_sidebar_html .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) @@ -135,7 +154,7 @@ pub fn PageLayout( - +
    @@ -288,10 +307,12 @@ mod tests { assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/ui/preferences")); assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localStorage.setItem")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("restoreSidebarTreeTab")); - assert!(SIDEBAR_TREE_RUNTIME_JS - .contains("applySearchSwitchState(overlay, { knowledge: false")); - assert!(!SIDEBAR_TREE_RUNTIME_JS - .contains("applySearchSwitchState(overlay, { knowledge: true")); + assert!( + SIDEBAR_TREE_RUNTIME_JS.contains("applySearchSwitchState(overlay, { knowledge: false") + ); + assert!( + !SIDEBAR_TREE_RUNTIME_JS.contains("applySearchSwitchState(overlay, { knowledge: true") + ); assert!(SIDEBAR_TREE_RUNTIME_JS .contains("data-search-switch=\"knowledge\" role=\"switch\" aria-checked=\"false\"")); assert!(!SIDEBAR_TREE_RUNTIME_JS @@ -400,6 +421,29 @@ mod tests { .contains("var expanded = expandable && item.expandedByDefault !== false"), "PageTree 不能只依赖 projection expandedByDefault 决定展开" ); + assert!( + render_page_rows.contains("item.expandedByDefault === true") + || render_page_rows.contains("expandedByDefault === true"), + "无 user view-state 时 PageTree 只能按 expandedByDefault === true 展开(与 SSR 一致)" + ); + + // PageTree view-state scope must be stable "root" (Sidex workspace-scoped), + // not current fileTreeScope — otherwise refresh under a folder loses expands. + let view_scope = + js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewScope"); + assert!( + view_scope.contains("pagetree") && view_scope.contains("'root'"), + "pagetree view-state scope must pin to root" + ); + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("function restorePersistedPageTreeExpansionState"), + "runtime must restore pagetree expandedIds after refresh" + ); + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function setPageTreeExpandPresentation"), + "runtime must own a single presentation write path for page expand" + ); let render_file_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderFileRows"); @@ -407,6 +451,154 @@ mod tests { assert!(render_file_rows.contains("expandedRelativePaths")); } + #[test] + fn page_tree_first_click_expands_ssr_children_without_waiting_for_fetch() { + // Shallow SSR paints nested rows under a collapsed container without + // data-page-tree-children-loaded. First toggle must expand immediately. + let toggle_children = + js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "toggleChildren"); + assert!( + toggle_children.contains("pageTreeRowIsOpen(row)"), + "toggleChildren must use visual open state, not aria-expanded alone" + ); + assert!( + toggle_children.contains("children.children.length > 0"), + "toggleChildren must expand when SSR already rendered child rows" + ); + assert!( + toggle_children.contains("data-page-tree-children-loaded"), + "toggleChildren must mark SSR children as loaded on first expand" + ); + + let load_page_children = + js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "loadPageTreeChildren"); + assert!( + load_page_children.contains("children.children.length > 0"), + "loadPageTreeChildren must treat non-empty SSR children as already loaded" + ); + assert!( + load_page_children.contains("Optimistic UI") + || load_page_children.contains("children.classList.remove('tree-children--collapsed')"), + "lazy expand must open chevron/container before network" + ); + + // Late view-state GET must not snap shut an expand the user just did + // (privacy window / cold localStorage is the common repro). + let load_view_state = + js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "loadSidebarTreeViewState"); + assert!( + load_view_state.contains("current.hasUserState"), + "async view-state load must merge with in-session expands" + ); + assert!( + load_view_state.contains("current.expandedIds.forEach"), + "async view-state load must union expandedIds from the session" + ); + + let apply_page_state = + js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "applyPageTreeExpansionState"); + assert!( + apply_page_state.contains("pageTreeRowIsOpen(row)"), + "applying view-state must preserve rows that are actually open this session" + ); + + // Boot must reconcile false "expanded" arrows before the first click, + // apply local view-state, and restore persisted expands (Sidex setInput). + assert!( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("function syncPageTreeExpandVisualState"), + "runtime must ship syncPageTreeExpandVisualState" + ); + let install = js_function_body( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, + "installTreeLiveApplyEventListeners", + ); + assert!( + install.contains("syncPageTreeExpandVisualState()"), + "install must run expand/chevron sync on boot" + ); + assert!( + install.contains("applyPageTreeExpansionState") + || install.contains("scheduleRestorePersistedPageTreeExpansionState"), + "install must apply/restore pagetree view-state on boot" + ); + + let boot_state = + js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewStateFor"); + assert!( + boot_state.contains("applySidebarTreeViewState"), + "localStorage view-state must apply to DOM immediately (not wait for API)" + ); + + // PageTree view-state scope must stay stable "root" (not fileTreeScope), + // otherwise refresh/navigation loses expandedIds under a different key. + let scope_fn = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "sidebarTreeViewScope"); + assert!( + scope_fn.contains("pagetree") && scope_fn.contains("'root'"), + "pagetree view-state scope must be stable root, not current fileTreeScope" + ); + + let restore_fn = js_function_body( + SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, + "restorePersistedPageTreeExpansionState", + ); + assert!( + !restore_fn.is_empty(), + "runtime must restore persisted page-tree expands after refresh" + ); + assert!( + restore_fn.contains("loadPageTreeChildren") || restore_fn.contains("setTreeRowExpanded"), + "restore must open SSR children or hydrate empty bodies" + ); + + let presentation = + js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "setPageTreeExpandPresentation"); + assert!( + presentation.contains("data-expanded") && presentation.contains("aria-expanded"), + "single write path must set both aria-expanded and data-expanded" + ); + + let render_rows = js_function_body(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS, "renderPageRows"); + assert!( + render_rows.contains("expandedByDefault === true") + || render_rows.contains("item.expandedByDefault === true"), + "client default expand must match SSR (only true, not !== false)" + ); + assert!( + render_rows.contains("data-expanded="), + "client-rendered page rows must emit data-expanded for chevron CSS" + ); + } + + #[test] + fn sidebar_tree_toggle_uses_wolai_style_rotating_chevron() { + // Hide non-rotating SSR SVG; show solid triangle via ::before that rotates 90°. + const MAIN_CSS: &str = include_str!("../styles/components/main.css"); + assert!( + MAIN_CSS.contains(".sidebar-tree .tree-toggle .tree-toggle-icon") + || MAIN_CSS.contains(".tree-toggle > svg"), + "main.css must hide the static SVG chevron inside tree-toggle" + ); + assert!( + MAIN_CSS.contains(".sidebar-tree .tree-toggle::before"), + "main.css must draw Wolai-style triangle via ::before" + ); + assert!( + MAIN_CSS.contains("rotate(90deg)"), + "main.css must rotate chevron when expanded" + ); + assert!( + MAIN_CSS.contains("data-expanded=\"true\"") + || MAIN_CSS.contains("[data-expanded=\"true\"]"), + "main.css must key rotation off data-expanded (real fold state)" + ); + // Icon must track real fold state (children container), not only aria. + assert!( + MAIN_CSS.contains("tree-children--collapsed") + && MAIN_CSS.contains(":has(> .tree-children"), + "main.css must bind chevron rotation to actual children open/collapsed state" + ); + } + #[test] fn page_layout_hides_public_state_and_exposes_sidebar_shortcut_star() { let html = crate::ssr::render_view(leptos::view! { @@ -650,6 +842,30 @@ mod tests { assert!(!folder_branch.contains("window.location.assign")); } + #[test] + fn vault_nav_soft_opens_without_replacing_sidebar_tree() { + // Left-rail 密码箱 must soft-swap .mnote-content so PageTree expand state survives. + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function openVaultWorkbenchSoft")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.__mnoteOpenVaultWorkbench")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("void openVaultWorkbenchSoft(vaultUrl)")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildVaultWorkbenchShellHtml")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("article.mnote-content")); + // Must not full-assign when soft path is taken (vault click branch). + let vault_branch = SIDEBAR_TREE_RUNTIME_JS + .split("var vaultNavLink = closestAction(") + .nth(1) + .and_then(|rest| rest.split("var localFolderTrigger").next()) + .expect("vault nav click branch exists"); + assert!( + vault_branch.contains("openVaultWorkbenchSoft"), + "vault click must soft-open workbench" + ); + assert!( + !vault_branch.contains("window.location.assign(nextHref)"), + "vault soft-open must not full-navigate on primary click" + ); + } + #[test] fn page_ai_context_uses_page_aggregate_subtree_as_single_truth() { assert!( @@ -1648,6 +1864,17 @@ mod tests { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-tree-live-error-schema")); } + #[test] + fn sidebar_filetree_watch_fallback_keeps_refresh_scoped() { + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("data-mnote-local-folder-watch-batch-fallback-scoped")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains( + "addCommandRefreshParent(fallbackParents, parentRelativePathForPath(relativePath))" + )); + assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS + .contains("fallbackResync === true || batch.requiresResync === true)) {\n document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-fallback', String(batch.revision || 'resync'));\n void refreshLocalFolderSidebarSnapshot();")); + } + #[test] fn sidebar_filetree_runtime_has_view_state_namespace() { assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var fileTreeViewState = {")); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 068a8b65..4b018984 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -17,6 +17,7 @@ /// - `styles/components/search.css`: 搜索弹窗组件 /// - `styles/components/page-ai.css`: Page AI 仪表盘组件 /// - `styles/components/ui-debug.css`: UI Debug 组件矩阵 +/// - `styles/components/vault.css`: 密码箱 workbench pub const MNOTE_CSS: &str = concat!( include_str!("styles/tokens.css"), "\n", @@ -37,6 +38,8 @@ pub const MNOTE_CSS: &str = concat!( include_str!("styles/components/page-ai.css"), "\n", include_str!("styles/components/ui-debug.css"), + "\n", + include_str!("styles/components/vault.css"), ); #[cfg(test)] @@ -215,8 +218,16 @@ mod tests { fn mnote_css_is_reasonably_sized() { // 至少 2000 字符才能包含完整样式 assert!(MNOTE_CSS.len() > 2000); - // CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装, - // 仍保持在可审阅范围内。 - assert!(MNOTE_CSS.len() < 190000); + // CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装; + // 当前包含主壳、Page AI、搜索、toast、debug 与 vault 样式,继续用上限防止意外重复打包。 + assert!(MNOTE_CSS.len() < 220000); + } + + #[test] + fn mnote_css_contains_vault_workbench_selectors() { + assert!(MNOTE_CSS.contains(".mnote-vault-workbench")); + assert!(MNOTE_CSS.contains(".mnote-vault-list-item")); + assert!(MNOTE_CSS.contains(".mnote-vault-secret-value")); + assert!(MNOTE_CSS.contains("[data-testid=\"mnote-nav-vault\"]") || MNOTE_CSS.contains(".mnote-vault-header")); } } diff --git a/rust/crates/mnote-web/src/ssr/styles/components/main.css b/rust/crates/mnote-web/src/ssr/styles/components/main.css index b7629634..dd850645 100644 --- a/rust/crates/mnote-web/src/ssr/styles/components/main.css +++ b/rust/crates/mnote-web/src/ssr/styles/components/main.css @@ -324,7 +324,7 @@ .sidebar-tree .tree-spacer { width: 20px; height: 24px; - color: #B8B5AF; + color: #9B968E; font-size: 16px; } @@ -335,29 +335,60 @@ justify-content: center; line-height: 1; font-size: 0; + /* Wolai-style: one clear chevron via ::before; hide SSR/JS SVG that does not rotate. */ +} + +.sidebar-tree .tree-toggle .tree-toggle-icon, +.sidebar-tree .tree-toggle > svg { + display: none !important; } .sidebar-tree .tree-toggle:hover { background: rgba(27, 28, 28, 0.06); + color: #6F6A62; } +/* Collapsed: solid right-pointing triangle (Wolai ▶). Expanded: rotate to ▼. + Prefer real fold state: aria-expanded / data-expanded must stay in lockstep + with .tree-children--collapsed (syncPageTreeExpandVisualState). */ .sidebar-tree .tree-toggle::before { content: ""; width: 0; height: 0; - border-top: 4px solid transparent; - border-bottom: 4px solid transparent; - border-left: 5px solid currentColor; + border-style: solid; + border-width: 4.5px 0 4.5px 7px; + border-color: transparent transparent transparent currentColor; display: block; - transform-origin: 50% 50%; + transform-origin: 40% 50%; + transition: transform 0.12s ease; } -.sidebar-tree .tree-row[aria-expanded="true"] > .tree-toggle::before { +/* Prefer data-expanded (set with children visibility) so aria alone cannot + * leave the chevron open while .tree-children--collapsed is present. */ +.sidebar-tree .tree-row[data-shell-mode="page"][data-expanded="true"] > .tree-toggle::before, +.sidebar-tree .tree-toggle[data-expanded="true"]::before, +.sidebar-tree .tree-row[aria-expanded="true"]:not([data-expanded="false"]) > .tree-toggle::before, +.sidebar-tree .tree-toggle[aria-expanded="true"]:not([data-expanded="false"])::before { transform: rotate(90deg); } +/* When children are visibly collapsed, never keep a rotated chevron. */ +.sidebar-tree .tree-node:has(> .tree-children.tree-children--collapsed) > .tree-row > .tree-toggle::before { + transform: none !important; +} + +/* Empty children body (lazy hydrate pending or failed): always ▶, never ▼. */ +.sidebar-tree .tree-node:has(> .tree-children:empty) > .tree-row > .tree-toggle::before { + transform: none !important; +} + +/* When children are open (not collapsed) AND non-empty, force rotated chevron. */ +.sidebar-tree .tree-node:has(> .tree-children:not(.tree-children--collapsed):not(:empty)) > .tree-row > .tree-toggle::before { + transform: rotate(90deg) !important; +} + .sidebar-tree .tree-row[data-active="true"] .tree-toggle { - color: #8F8A84; + color: #6F6A62; } .sidebar-tree:not([data-tree-shell-mode="filetree"]) .tree-kind-badge[data-kind="page"] { @@ -1058,6 +1089,32 @@ white-space: nowrap; } +.wolai-breadcrumb-pages { + display: inline-flex; + align-items: center; + gap: 9px; + min-width: 0; + overflow: hidden; +} + +.wolai-breadcrumb-link, +.wolai-breadcrumb-current { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wolai-breadcrumb-link { + color: #6D6A65; + text-decoration: none; +} + +.wolai-breadcrumb-link:hover { + color: var(--atelier-text); + text-decoration: underline; + text-underline-offset: 3px; +} + .wolai-breadcrumb-separator { color: #D0CDC8; } @@ -2069,6 +2126,32 @@ text-underline-offset: 6px; } +.document-edit-mode-toggle { + min-width: 76px; + height: 32px; + margin-top: 8px; + border: 1px solid rgba(55, 53, 47, 0.12); + border-radius: 6px; + background: #FFFFFF; + color: #37352F; + font-size: 13px; + line-height: 1; + cursor: pointer; +} + +.document-edit-mode-toggle:hover { + background: rgba(55, 53, 47, 0.06); +} + +.document-edit-mode-toggle[data-document-editing="true"] { + border-color: rgba(35, 131, 226, 0.24); + color: #0F6CBD; +} + +.document-title-input[readonly] { + cursor: default; +} + .document-pane-close { width: 28px; height: 28px; diff --git a/rust/crates/mnote-web/src/ssr/styles/components/vault.css b/rust/crates/mnote-web/src/ssr/styles/components/vault.css new file mode 100644 index 00000000..9c54860b --- /dev/null +++ b/rust/crates/mnote-web/src/ssr/styles/components/vault.css @@ -0,0 +1,734 @@ +/* Password vault workbench — dedicated /vault CRUD surface */ + +.mnote-vault-workbench { + display: flex; + flex-direction: column; + gap: 16px; + width: min(1120px, calc(100vw - 48px)); + min-height: calc(100vh - 120px); + margin: 28px auto 40px; + color: var(--atelier-text, #1b1c1c); +} + +.mnote-vault-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 16px 24px; +} + +.mnote-vault-header-main { + min-width: 0; + flex: 1 1 280px; +} + +.mnote-vault-header h1 { + margin: 0 0 6px; + font-size: 28px; + font-weight: 650; + line-height: 36px; +} + +.mnote-vault-header-main > p { + margin: 0; + color: #6d6a65; + font-size: 13px; + line-height: 20px; +} + +.mnote-vault-status { + margin: 8px 0 0; + min-height: 18px; + font-size: 12px; + line-height: 18px; + color: #8b8782; +} + +.mnote-vault-status[data-type="error"] { + color: #c93a32; +} + +.mnote-vault-status[data-type="success"] { + color: #23834d; +} + +.mnote-vault-status[data-type="info"] { + color: #6d6a65; +} + +.mnote-vault-header-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + flex: 0 1 auto; +} + +.mnote-vault-header-actions > button, +.mnote-vault-detail-actions button, +.mnote-vault-secret-actions button, +.mnote-vault-secret-edit button, +.mnote-vault-tabs button { + height: 30px; + padding: 0 12px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #ffffff; + color: #37352f; + font: inherit; + font-size: 12px; + line-height: 28px; + cursor: pointer; +} + +.mnote-vault-header-actions > button:hover, +.mnote-vault-detail-actions button:hover, +.mnote-vault-secret-actions button:hover, +.mnote-vault-secret-edit button:hover, +.mnote-vault-tabs button:hover { + background: #f7f7f6; +} + +.mnote-vault-detail-actions button.is-danger { + color: #c93a32; + border-color: rgba(201, 58, 50, 0.28); +} + +.mnote-vault-detail-actions button.is-danger:hover { + background: #fdf2f1; +} + +.mnote-vault-header-actions input[type="search"] { + width: min(240px, 42vw); + height: 30px; + padding: 0 10px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #ffffff; + color: inherit; + font: inherit; + font-size: 13px; +} + +.mnote-vault-tabs { + display: inline-flex; + gap: 0; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + overflow: hidden; +} + +.mnote-vault-tabs button { + border: 0; + border-radius: 0; + background: #ffffff; +} + +.mnote-vault-tabs button.is-active, +.mnote-vault-tabs button[aria-selected="true"] { + background: #f4f3f3; + font-weight: 600; +} + +.mnote-vault-body { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 0; + min-height: 480px; + border: 1px solid rgba(27, 28, 28, 0.1); + border-radius: 6px; + background: #ffffff; + overflow: hidden; +} + +.mnote-vault-list { + min-height: 0; + max-height: calc(100vh - 220px); + overflow-y: auto; + overscroll-behavior: contain; + border-right: 1px solid rgba(27, 28, 28, 0.08); + background: #fafaf9; +} + +.mnote-vault-list-item { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + width: 100%; + min-height: 28px; + margin: 0; + padding: 4px 8px 4px 10px; + border: 0; + border-bottom: 1px solid rgba(27, 28, 28, 0.04); + background: transparent; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; +} + +.mnote-vault-list-item:hover { + background: #f1f0ef; +} + +.mnote-vault-list-item.is-active { + background: #ebe9e7; + box-shadow: inset 2px 0 0 #1b1c1c; +} + +.mnote-vault-list-main { + display: flex; + flex: 1 1 auto; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.mnote-vault-list-title { + flex: 0 1 auto; + max-width: 62%; + overflow: hidden; + color: #1b1c1c; + font-size: 12px; + font-weight: 600; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mnote-vault-list-meta { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: #9b9a97; + font-size: 11px; + font-weight: 400; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mnote-vault-list-icons { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: auto; + line-height: 1; +} + +.mnote-vault-list-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + font-size: 11px; + font-weight: 700; + line-height: 1; + opacity: 0.9; +} + +.mnote-vault-list-icon.is-shared { + color: #2e7d32; +} + +.mnote-vault-list-icon.is-ai { + color: #1565c0; + font-size: 9px; +} + +.mnote-vault-list-tags { + display: none; +} + +.mnote-vault-tag { + display: inline-block; + padding: 1px 6px; + border-radius: 999px; + background: rgba(27, 28, 28, 0.06); + color: #5a5a5a; + font-size: 11px; + line-height: 16px; +} + +.mnote-vault-tag--shared { + background: rgba(46, 125, 50, 0.12); + color: #2e7d32; +} + +.mnote-vault-tag--ai { + background: rgba(25, 118, 210, 0.12); + color: #1565c0; +} + +.mnote-vault-role-badge { + display: inline-flex; + align-items: center; + margin-top: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 18px; + background: rgba(25, 118, 210, 0.12); + color: #1565c0; +} + +.mnote-vault-detail-title { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.mnote-vault-detail-title-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mnote-vault-status-chip { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: 999px; + font-size: 11px; + font-weight: 650; + line-height: 16px; + letter-spacing: 0.01em; +} + +.mnote-vault-status-chip.is-shared { + background: rgba(46, 125, 50, 0.12); + color: #2e7d32; +} + +.mnote-vault-status-chip.is-ai { + background: rgba(25, 118, 210, 0.12); + color: #1565c0; +} + +.mnote-vault-hint-mini { + margin: 4px 0 0; + color: #9b9a97; + font-size: 12px; + line-height: 18px; +} + +.mnote-vault-linkish { + border: 0; + padding: 0; + background: none; + color: #1565c0; + font: inherit; + font-size: inherit; + text-decoration: underline; + cursor: pointer; +} + +.mnote-vault-list-item.is-shared-ai { + box-shadow: inset 3px 0 0 #2e7d32; +} + +.mnote-vault-list-item.is-ai-copy { + box-shadow: inset 3px 0 0 #1565c0; +} + +.mnote-vault-workbench[data-vault-role="ai"] .mnote-vault-header h1 { + color: #1565c0; +} + +.mnote-vault-detail { + min-width: 0; + min-height: 0; + max-height: calc(100vh - 220px); + overflow-y: auto; + overscroll-behavior: contain; + padding: 18px 22px 28px; +} + +.mnote-vault-empty { + padding: 28px 12px; + color: #8b8782; + font-size: 13px; + line-height: 20px; +} + +.mnote-vault-detail-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.mnote-vault-detail-header h2 { + margin: 0; + min-width: 0; + font-size: 20px; + font-weight: 650; + line-height: 28px; +} + +.mnote-vault-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mnote-vault-fields { + display: flex; + flex-direction: column; + gap: 10px; +} + +.mnote-vault-field-row { + display: grid; + grid-template-columns: 96px minmax(0, 1fr) auto; + align-items: start; + gap: 8px 12px; + padding: 8px 0; + border-top: 1px solid rgba(27, 28, 28, 0.06); +} + +.mnote-vault-field-row > label { + padding-top: 4px; + color: #6d6a65; + font-size: 12px; + font-weight: 600; + line-height: 18px; +} + +.mnote-vault-field-row > div, +.mnote-vault-field-row > pre { + min-width: 0; + margin: 0; + font-size: 13px; + line-height: 20px; + word-break: break-word; +} + +.mnote-vault-field-row input[type="text"], +.mnote-vault-field-row input[type="password"], +.mnote-vault-field-row textarea { + width: 100%; + box-sizing: border-box; + min-height: 30px; + padding: 6px 10px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #ffffff; + color: inherit; + font: inherit; + font-size: 13px; + line-height: 18px; +} + +.mnote-vault-field-row textarea { + min-height: 120px; + resize: vertical; +} + +.mnote-vault-secret-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.04em; +} + +.mnote-vault-secret-actions, +.mnote-vault-secret-edit { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.mnote-vault-secret-edit { + grid-column: 2 / -1; +} + +.mnote-vault-secret-edit input { + flex: 1 1 180px; +} + +.mnote-vault-muted { + color: #9b9a97; +} + +.mnote-vault-notes { + grid-template-columns: 96px minmax(0, 1fr); +} + +.mnote-vault-notes pre { + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 18px; + color: #37352f; +} + +.mnote-vault-form .mnote-vault-field-row { + grid-template-columns: 120px minmax(0, 1fr); +} + +.mnote-vault-folder-controls { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.mnote-vault-folder-controls select { + width: 100%; + box-sizing: border-box; + min-height: 30px; + padding: 6px 10px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #ffffff; + color: inherit; + font: inherit; + font-size: 13px; + line-height: 18px; +} + +.mnote-vault-folder-controls input[type="text"] { + width: 100%; + box-sizing: border-box; +} + +@media (max-width: 820px) { + .mnote-vault-workbench { + width: calc(100vw - 24px); + margin: 18px auto 28px; + } + + .mnote-vault-body { + grid-template-columns: 1fr; + min-height: 0; + } + + .mnote-vault-list { + max-height: 220px; + border-right: 0; + border-bottom: 1px solid rgba(27, 28, 28, 0.08); + } + + .mnote-vault-detail { + max-height: none; + } + + .mnote-vault-field-row { + grid-template-columns: 1fr; + } + + .mnote-vault-form .mnote-vault-field-row { + grid-template-columns: 1fr; + } + + .mnote-vault-secret-edit { + grid-column: auto; + } +} + +/* Folder tree list */ +.mnote-vault-folder { + border-bottom: 1px solid rgba(27, 28, 28, 0.05); +} + +.mnote-vault-site-group { + border-bottom: 0; +} + +.mnote-vault-site-toggle .mnote-vault-folder-name { + font-weight: 500; + color: #5c5a56; +} + +.mnote-vault-secret-input-plain { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + letter-spacing: 0.01em; +} + +.mnote-vault-folder-label { + padding: 4px 10px 2px; + color: #8b8782; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.mnote-vault-folder-toggle { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + margin: 0; + padding: 3px 8px 3px calc(8px + var(--vault-depth, 0) * 10px); + border: 0; + background: transparent; + color: #37352f; + font: inherit; + font-size: 11px; + font-weight: 600; + line-height: 16px; + text-align: left; + cursor: pointer; +} + +.mnote-vault-folder-toggle:hover { + background: #f1f0ef; +} + +.mnote-vault-folder-chevron { + display: inline-block; + width: 12px; + color: #8b8782; + font-size: 11px; +} + +.mnote-vault-folder-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mnote-vault-folder-count { + flex: 0 0 auto; + color: #8b8782; + font-size: 11px; + font-weight: 500; +} + +.mnote-vault-list-item-wrap { + padding-left: calc(var(--vault-depth, 0) * 10px); +} + +.mnote-vault-list-item-wrap .mnote-vault-list-item { + border-bottom-color: rgba(27, 28, 28, 0.04); +} + +.mnote-vault-hint { + margin: 8px 0 0; + color: #8b8782; + font-size: 12px; + line-height: 18px; +} + +.mnote-vault-hint code { + padding: 0 4px; + border-radius: 3px; + background: #f1f0ef; + font-size: 11px; +} + +.mnote-vault-sibling-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.mnote-vault-sibling { + height: 26px; + padding: 0 10px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #ffffff; + color: #37352f; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.mnote-vault-sibling:hover { + background: #f7f7f6; +} + +/* Cipher book panel */ +.mnote-vault-cipher-add { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 12px 0 16px; +} + +.mnote-vault-cipher-add input { + flex: 1 1 120px; + min-width: 100px; + height: 30px; + padding: 0 10px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + font: inherit; + font-size: 13px; +} + +.mnote-vault-cipher-add button { + height: 30px; + padding: 0 12px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #ffffff; + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.mnote-vault-cipher-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr) auto; + gap: 8px 12px; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(27, 28, 28, 0.06); +} + +.mnote-vault-cipher-key { + font-size: 13px; + font-weight: 600; +} + +.mnote-vault-cipher-value { + overflow: hidden; + color: #37352f; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mnote-vault-cipher-actions { + display: flex; + gap: 6px; +} + +.mnote-vault-cipher-actions button { + height: 26px; + padding: 0 8px; + border: 1px solid rgba(27, 28, 28, 0.12); + border-radius: 4px; + background: #ffffff; + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.mnote-vault-cipher-actions button.is-danger { + color: #c93a32; + border-color: rgba(201, 58, 50, 0.28); +} + +@media (max-width: 820px) { + .mnote-vault-cipher-row { + grid-template-columns: 1fr; + } +} diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs index c32d5696..0f7dc06b 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -129,6 +129,14 @@ fn render_filetree_row( html.push_str(""); } +/// Placeholder FileTree shell for shell-first home SSR. +/// Client hydrates rows via `/api/tree/projections/file` without blocking first paint. +pub fn render_filetree_pending_shell_html() -> String { + String::from( + r#"
      "#, + ) +} + pub fn render_initial_filetree_html(input: &FileTreeInitialRenderInput) -> String { let mut html = String::from( r#"
        "#, @@ -306,4 +314,13 @@ mod tests { assert!(html.contains(r#"data-index-status="indexed""#)); assert!(html.contains(r#""#, + r#""#, node_id = escape_html(&row.node_id), label = if expanded { "折叠" } else { "展开" }, title = escape_html(&row.title), @@ -115,21 +117,29 @@ fn render_page_row( } else { r#""#.to_string() }; - let parent_attr = input + let source_row = input .rows .iter() - .find(|source| source.node_id == row.node_id) + .find(|source| source.node_id == row.node_id); + let parent_attr = source_row .and_then(|source| source.parent_node_id.as_deref()) .map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id))) .unwrap_or_default(); + let expand_path_attr = source_row + .and_then(|source| source.expand_relative_path.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|path| format!(r#" data-local-relative-path="{}""#, escape_html(path))) + .unwrap_or_default(); let render_depth = render_depth_for_node(&input.rows, &row.node_id, row.depth); html.push_str(&format!( - r#"
      • {toggle_html}
        "#, + r#"
      • {toggle_html}
        "#, node_id = escape_html(&row.node_id), aria_level = render_depth + 1, expanded_attr = if row.expandable && expanded { "true" } else { "false" }, test_id = row.test_id, parent_attr = parent_attr, + expand_path_attr = expand_path_attr, depth = render_depth, active = active, selected = active, @@ -140,18 +150,22 @@ fn render_page_row( toggle_html = toggle_html, title = escape_html(&row.title), )); + // Always emit a children container for expandable rows so lazy expand can + // inject scope rows without a second full-tree snapshot (Sidex-style). if row.expandable { - if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) { - html.push_str(if expanded { - r#"
          "# - } else { - r#"
            "# - }); - for child in children { - render_page_row(html, child, children_by_parent, input); - } - html.push_str("
          "); + let children = children_by_parent + .get(&Some(row.node_id.clone())) + .map(Vec::as_slice) + .unwrap_or(&[]); + html.push_str(if expanded { + r#"
            "# + } else { + r#"
              "# + }); + for child in children { + render_page_row(html, child, children_by_parent, input); } + html.push_str("
            "); } html.push_str(""); } @@ -210,6 +224,7 @@ mod tests { expandable: true, expanded: false, openable: true, + expand_relative_path: Some("home".into()), }, PageTreeRenderRow { node_id: "page_child".into(), @@ -219,6 +234,7 @@ mod tests { expandable: false, expanded: false, openable: true, + expand_relative_path: None, }, ]); @@ -242,6 +258,7 @@ mod tests { expandable: true, expanded: true, openable: true, + expand_relative_path: Some("home".into()), }, PageTreeRenderRow { node_id: "page_child".into(), @@ -251,6 +268,7 @@ mod tests { expandable: false, expanded: false, openable: true, + expand_relative_path: None, }, ], active_node_id: Some("page_root".into()), @@ -266,9 +284,31 @@ mod tests { assert!(html.contains("data-rust-action=\"create\"")); assert!(html.contains("draggable=\"true\"")); assert!(html.contains("tree-children")); + assert!(html.contains(r#"data-local-relative-path="home""#)); assert!(html.contains("子页")); assert!(html.contains("首页 <安全>")); assert!(html.contains("data-active=\"true\"")); assert!(html.contains("data-focused=\"true\"")); } + + #[test] + fn tree_shell_page_renderer_emits_empty_children_container_for_lazy_expandable() { + let html = render_initial_page_tree_html(&PageTreeInitialRenderInput { + rows: vec![PageTreeRenderRow { + node_id: "page_lazy".into(), + parent_node_id: None, + title: "可展开".into(), + depth: 0, + expandable: true, + expanded: false, + openable: true, + expand_relative_path: Some("docs".into()), + }], + active_node_id: None, + focused_node_id: None, + }); + assert!(html.contains("tree-children--collapsed")); + assert!(html.contains(r#"data-local-relative-path="docs""#)); + assert!(html.contains("data-rust-action=\"toggle\"")); + } } diff --git a/rust/crates/mnote-web/src/workspace_shell.rs b/rust/crates/mnote-web/src/workspace_shell.rs index aa21f415..6181b8b3 100644 --- a/rust/crates/mnote-web/src/workspace_shell.rs +++ b/rust/crates/mnote-web/src/workspace_shell.rs @@ -46,6 +46,64 @@ pub struct WorkspaceShellEntry { pub icon: String, } +/// 使用页面树投影生成顶栏页面祖先链。 +pub fn render_page_breadcrumb_html( + projection: &WorkspaceShellProjection, + active_page_id: Option<&str>, +) -> String { + let Some(active_page_id) = active_page_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return String::new(); + }; + let by_id = projection + .my_page_items + .iter() + .map(|item| (item.id.as_str(), item)) + .collect::>(); + let mut chain = Vec::new(); + let mut cursor = Some(active_page_id); + let mut seen = std::collections::HashSet::new(); + while let Some(id) = cursor { + if !seen.insert(id) { + break; + } + let Some(item) = by_id.get(id) else { + break; + }; + chain.push(*item); + cursor = item.parent_id.as_deref(); + if chain.len() >= 64 { + break; + } + } + chain.reverse(); + chain + .iter() + .enumerate() + .map(|(index, item)| { + let separator = if index == 0 { + String::new() + } else { + r#""#.to_string() + }; + let title = escape_html(&item.title); + let id = escape_html(&item.id); + if index + 1 == chain.len() { + format!( + r#"{separator}{title}"# + ) + } else { + format!( + r#"{separator}{title}"#, + escape_html(&item.href) + ) + } + }) + .collect() +} + pub fn build_workspace_shell_projection( dataset: &Value, workspace_id: &str, @@ -553,6 +611,32 @@ mod tests { .any(|entry| entry.label == "模板中心")); } + #[test] + fn page_breadcrumb_renders_all_ancestors_as_links() { + let projection = build_workspace_shell_projection( + &json!({ + "workspaces": [{ "id": "ws_demo", "name": "我的空间" }], + "documents": [ + { "id": "root", "workspace_id": "ws_demo", "title": "个人", "parent_id": null }, + { "id": "parent", "workspace_id": "ws_demo", "title": "密码", "parent_id": "root" }, + { "id": "current", "workspace_id": "ws_demo", "title": "deepseek ai", "parent_id": "parent" } + ] + }), + "ws_demo", + Some("current"), + "我的空间", + ); + + let html = render_page_breadcrumb_html(&projection, Some("current")); + assert!(html.contains(r#"data-breadcrumb-document-id="root""#)); + assert!(html.contains(r#"data-breadcrumb-document-id="parent""#)); + assert!(html.contains(r#"data-breadcrumb-document-id="current""#)); + assert!(html.contains(r#"href="/documents/root?workspaceId=ws_demo""#)); + assert!(html.contains(r#"href="/documents/parent?workspaceId=ws_demo""#)); + assert!(html.contains("deepseek ai")); + assert_eq!(html.matches("wolai-breadcrumb-separator").count(), 2); + } + #[test] fn workspace_shell_sidebar_html_outputs_projection_rows_with_active_and_parent() { let dataset = json!({ @@ -652,6 +736,7 @@ mod tests { assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree" hidden"#)); assert!(html.contains(r#"id="sidebar-tree-root""#)); assert!(html.contains(r#"id="sidebar-file-tree-root""#)); + assert!(!html.contains(r#"data-filetree-ssr="pending""#)); assert!(!html.contains("wolai-file-tree-section")); } @@ -687,6 +772,27 @@ mod tests { assert!(html.contains(r#"data-mnote-sidebar-tree-panel="filetree">
          "#), + Some(r#"
            "#), + None, + None, + ); + assert!(html.contains(r#"id="sidebar-file-tree-root""#)); + assert!(html.contains(r#"data-filetree-ssr="pending""#)); + assert!(html.contains(r#"data-rust-filetree-renderer="pending_shell_v1""#)); + assert!(html.contains(r#"aria-busy="true""#)); + } + #[test] fn workspace_shell_sidebar_html_marks_filetree_scope() { let dataset = json!({ @@ -805,8 +911,17 @@ pub fn render_workspace_shell_sidebar_html( ) }) .unwrap_or_default(); + // Propagate shell-first pending marker onto the FileTree root so the client + // can hydrate without scanning for nested placeholders. + let pending_attr = if html.contains("data-filetree-ssr=\"pending\"") + || html.contains("data-rust-filetree-renderer=\"pending_shell_v1\"") + { + r#" data-filetree-ssr="pending" aria-busy="true""# + } else { + "" + }; format!( - r#""#, + r#""#, escape_html(&projection.workspace_id), ) }) diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_handle_menu_view.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_handle_menu_view.rs index 8d568277..92b2b8aa 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_handle_menu_view.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_handle_menu_view.rs @@ -1,7 +1,8 @@ use crate::editor_runtime::{ block_hover_state::{BlockMenuLayout, HoveredBlockState}, block_transform::{ - apply_text_align, run_block_turn_into_action, run_block_turn_into_folded_heading_action, + apply_highlight_color, apply_text_align, apply_text_color, clear_text_color, + run_block_turn_into_action, run_block_turn_into_folded_heading_action, run_block_turn_into_page_action, top_level_block_is_page, top_level_block_matches_action, top_level_block_range, }, @@ -19,9 +20,9 @@ use crate::{ }; use leptos::prelude::*; use leptos_tiptap::{TiptapEditorHandle, TiptapSelectionState, TiptapTextAlign}; -use serde_json::Value; +use serde_json::{json, Value}; use wasm_bindgen_futures::spawn_local; -use web_sys::{MouseEvent, WheelEvent}; +use web_sys::{window, CustomEvent, CustomEventInit, MouseEvent, WheelEvent}; #[derive(Clone, Copy)] struct FoldedHeadingAction { @@ -58,6 +59,167 @@ const FOLDED_HEADING_ACTIONS: [FoldedHeadingAction; 4] = [ }, ]; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg(test)] +enum BlockMenuSecondarySurface { + InlineFeedback, + Dialog, + Submenu, +} + +#[derive(Clone, Copy)] +#[cfg(test)] +struct BlockMenuSecondaryActionSpec { + test_id: &'static str, + surface: BlockMenuSecondarySurface, +} + +#[cfg(test)] +const fn block_menu_secondary_action_specs() -> [BlockMenuSecondaryActionSpec; 5] { + [ + BlockMenuSecondaryActionSpec { + test_id: "block-drag-menu-item-copy-link", + surface: BlockMenuSecondarySurface::InlineFeedback, + }, + BlockMenuSecondaryActionSpec { + test_id: "block-drag-menu-item-move", + surface: BlockMenuSecondarySurface::Dialog, + }, + BlockMenuSecondaryActionSpec { + test_id: "block-drag-menu-item-history", + surface: BlockMenuSecondarySurface::Dialog, + }, + BlockMenuSecondaryActionSpec { + test_id: "block-drag-menu-item-comment", + surface: BlockMenuSecondarySurface::Dialog, + }, + BlockMenuSecondaryActionSpec { + test_id: "block-drag-menu-item-color", + surface: BlockMenuSecondarySurface::Submenu, + }, + ] +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum BlockAuxPanel { + Move, + History, + Comment, +} + +#[derive(Clone, Copy)] +struct BlockColorOption { + id: &'static str, + label: &'static str, + value: &'static str, +} + +const BLOCK_TEXT_COLORS: [BlockColorOption; 5] = [ + BlockColorOption { + id: "default", + label: "默认", + value: "#37352f", + }, + BlockColorOption { + id: "gray", + label: "灰色", + value: "#787774", + }, + BlockColorOption { + id: "red", + label: "红色", + value: "#e03e3e", + }, + BlockColorOption { + id: "blue", + label: "蓝色", + value: "#337ea9", + }, + BlockColorOption { + id: "green", + label: "绿色", + value: "#448361", + }, +]; + +const BLOCK_HIGHLIGHT_COLORS: [BlockColorOption; 5] = [ + BlockColorOption { + id: "gray", + label: "灰底", + value: "#f1f1ef", + }, + BlockColorOption { + id: "red", + label: "红底", + value: "#fdebec", + }, + BlockColorOption { + id: "yellow", + label: "黄底", + value: "#fbf3db", + }, + BlockColorOption { + id: "blue", + label: "蓝底", + value: "#e7f3f8", + }, + BlockColorOption { + id: "green", + label: "绿底", + value: "#edf3ec", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secondary_block_menu_entries_are_real_surfaces_not_placeholder_feedback() { + let specs = block_menu_secondary_action_specs(); + assert_eq!( + specs + .iter() + .find(|spec| spec.test_id == "block-drag-menu-item-copy-link") + .unwrap() + .surface, + BlockMenuSecondarySurface::InlineFeedback + ); + assert_eq!( + specs + .iter() + .find(|spec| spec.test_id == "block-drag-menu-item-move") + .unwrap() + .surface, + BlockMenuSecondarySurface::Dialog + ); + assert_eq!( + specs + .iter() + .find(|spec| spec.test_id == "block-drag-menu-item-history") + .unwrap() + .surface, + BlockMenuSecondarySurface::Dialog + ); + assert_eq!( + specs + .iter() + .find(|spec| spec.test_id == "block-drag-menu-item-comment") + .unwrap() + .surface, + BlockMenuSecondarySurface::Dialog + ); + assert_eq!( + specs + .iter() + .find(|spec| spec.test_id == "block-drag-menu-item-color") + .unwrap() + .surface, + BlockMenuSecondarySurface::Submenu + ); + } +} + pub(crate) struct BlockHandleMenuViewProps { pub(crate) editor: TiptapEditorHandle, pub(crate) block: HoveredBlockState, @@ -115,6 +277,106 @@ fn close_block_transform_menus(signals: BlockHandleMenuSignals) { signals.set_block_folded_title_open.set(false); } +fn dispatch_mnote_toast(message: &str, kind: &str) { + let Some(win) = window() else { + return; + }; + let Ok(detail) = serde_wasm_bindgen::to_value(&json!({ + "message": message, + "kind": kind, + })) else { + return; + }; + let init = CustomEventInit::new(); + init.set_detail(&detail); + if let Ok(event) = CustomEvent::new_with_event_init_dict("mnote:toast", &init) { + let _ = win.dispatch_event(&event); + } +} + +fn set_current_block_selection( + editor: TiptapEditorHandle, + block_index: usize, +) -> Result<(), String> { + let document = editor + .get_json() + .map_err(|err| format!("读取当前 JSON 失败:{err}"))?; + let range = top_level_block_range(&document, block_index) + .ok_or_else(|| format!("找不到第 {} 个块的选择范围", block_index + 1))?; + editor + .set_text_selection(range) + .map_err(|err| format!("选中当前块失败:{err}")) +} + +fn persist_block_menu_command( + editor: TiptapEditorHandle, + signals: BlockHandleMenuSignals, + success_message: &str, +) { + let (html, snapshot, json_text) = read_editor_snapshot(editor); + signals.set_dirty_count.update(|count| *count += 1); + signals.set_html_output.set(html.clone()); + signals.set_document_json.set(snapshot.clone()); + signals.set_json_output.set(json_text); + match persist_document_state( + &runtime_persisted_identity(signals.document_id, signals.workspace_id), + &signals.title.get_untracked(), + &snapshot, + Some(html), + ) { + Ok(()) => signals + .set_command_feedback + .set(format!("{success_message},并已写入本地草稿")), + Err(err) => signals + .set_command_feedback + .set(format!("{success_message},但本地保存失败:{err}")), + } +} + +fn handle_block_text_color_click( + editor: TiptapEditorHandle, + block_index: usize, + value: &'static str, + signals: BlockHandleMenuSignals, +) { + let result = set_current_block_selection(editor, block_index) + .and_then(|_| apply_text_color(editor, value).map(str::to_string)); + match result { + Ok(message) => persist_block_menu_command(editor, signals, &message), + Err(err) => signals.set_command_feedback.set(err), + } +} + +fn handle_block_highlight_color_click( + editor: TiptapEditorHandle, + block_index: usize, + value: &'static str, + signals: BlockHandleMenuSignals, +) { + let result = set_current_block_selection(editor, block_index) + .and_then(|_| apply_highlight_color(editor, Some(value)).map(str::to_string)); + match result { + Ok(message) => persist_block_menu_command(editor, signals, &message), + Err(err) => signals.set_command_feedback.set(err), + } +} + +fn handle_block_clear_color_click( + editor: TiptapEditorHandle, + block_index: usize, + signals: BlockHandleMenuSignals, +) { + let result = set_current_block_selection(editor, block_index).and_then(|_| { + let message = clear_text_color(editor)?; + let _ = apply_highlight_color(editor, None)?; + Ok(message.to_string()) + }); + match result { + Ok(message) => persist_block_menu_command(editor, signals, &message), + Err(err) => signals.set_command_feedback.set(err), + } +} + fn handle_turn_into_action_click( editor: TiptapEditorHandle, block_index: usize, @@ -358,6 +620,8 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In let block_index = block.index; let menu_top = format!("{}px", menu_layout.top); let menu_bottom = "auto"; + let (block_aux_panel, set_block_aux_panel) = signal::>(None); + let (block_color_open, set_block_color_open) = signal(false); view! {
            impl In data-testid="block-drag-menu-item-copy-link" on:click=move |event: MouseEvent| { event.stop_propagation(); + set_block_aux_panel.set(None); + set_block_color_open.set(false); let Some(block_id) = runtime_block_id_from_index(block_index) else { - signals - .set_command_feedback - .set("当前块缺少 Rust block id,无法复制锚点链接".to_string()); + let message = "当前块缺少 Rust block id,无法复制锚点链接"; + signals.set_command_feedback.set(message.to_string()); + dispatch_mnote_toast(message, "warning"); return; }; let Some(anchor_url) = current_page_anchor_url(&block_id) else { - signals - .set_command_feedback - .set("当前页面 URL 不可用,无法复制锚点链接".to_string()); + let message = "当前页面 URL 不可用,无法复制锚点链接"; + signals.set_command_feedback.set(message.to_string()); + dispatch_mnote_toast(message, "warning"); return; }; if let Some(block_element) = editor_block_by_id(&block_id) { @@ -527,12 +793,14 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In spawn_local(async move { match write_mnote_text_to_clipboard(anchor_url.clone()).await { Ok(value) if value.as_bool().unwrap_or(false) => { - signals.set_command_feedback.set(format!("已复制块链接:{}", block_id)); + let message = format!("已复制块链接:{}", block_id); + signals.set_command_feedback.set(message.clone()); + dispatch_mnote_toast(&message, "success"); } _ => { - signals - .set_command_feedback - .set(format!("块链接已生成但剪贴板写入失败:{}", anchor_url)); + let message = format!("块链接已生成但剪贴板写入失败:{}", anchor_url); + signals.set_command_feedback.set(message.clone()); + dispatch_mnote_toast(&message, "warning"); } } }); @@ -547,9 +815,9 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In data-testid="block-drag-menu-item-move" on:click=move |event: MouseEvent| { event.stop_propagation(); - signals - .set_command_feedback - .set("移动/嵌入流程后续接 Page Aggregate 命令".to_string()); + set_block_color_open.set(false); + set_block_aux_panel.set(Some(BlockAuxPanel::Move)); + signals.set_command_feedback.set("已打开移动/嵌入面板".to_string()); } > {material_icon("arrow_outward")} @@ -561,9 +829,9 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In data-testid="block-drag-menu-item-history" on:click=move |event: MouseEvent| { event.stop_propagation(); - signals - .set_command_feedback - .set("块历史入口已记录,恢复流程后续接版本链路".to_string()); + set_block_color_open.set(false); + set_block_aux_panel.set(Some(BlockAuxPanel::History)); + signals.set_command_feedback.set("已打开块历史面板".to_string()); } > {material_icon("history")} @@ -574,7 +842,9 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In data-testid="block-drag-menu-item-comment" on:click=move |event: MouseEvent| { event.stop_propagation(); - signals.set_command_feedback.set("评论入口已打开".to_string()); + set_block_color_open.set(false); + set_block_aux_panel.set(Some(BlockAuxPanel::Comment)); + signals.set_command_feedback.set("已打开评论面板".to_string()); } > {material_icon("chat")} @@ -585,15 +855,13 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In class="block-drag-menu-item" data-testid="block-drag-menu-item-color" on:mouseenter=move |_| { - signals - .set_command_feedback - .set("颜色子菜单后续按 Wolai 继续补齐".to_string()); + set_block_aux_panel.set(None); + set_block_color_open.set(true); } on:click=move |event: MouseEvent| { event.stop_propagation(); - signals - .set_command_feedback - .set("颜色子菜单后续按 Wolai 继续补齐".to_string()); + set_block_aux_panel.set(None); + set_block_color_open.update(|open| *open = !*open); } > {material_icon("palette")} @@ -603,17 +871,13 @@ pub(crate) fn block_handle_menu_view(props: BlockHandleMenuViewProps) -> impl In + } + }).collect_view()} +
            +
        +
        +
        "背景颜色"
        +
        + {BLOCK_HIGHLIGHT_COLORS.iter().map(|option| { + let value = option.value; + let label = option.label; + let testid = format!("block-highlight-color-{}", option.id); + view! { + + } + }).collect_view()} +
        +
        +
        + +
        +
        + }.into_any() + } else { + ().into_any() + } + }} + {move || { + match block_aux_panel.get() { + Some(BlockAuxPanel::Move) => view! { + + }.into_any(), + Some(BlockAuxPanel::History) => view! { + + }.into_any(), + Some(BlockAuxPanel::Comment) => view! { + + }.into_any(), + None => ().into_any(), + } + }} } diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs index 3e93f825..8072d698 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_transform.rs @@ -10,6 +10,7 @@ use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::{spawn_local, JsFuture}; use web_sys::{window, CustomEvent, CustomEventInit, Node, RequestInit, RequestMode, Response}; +use crate::editor_root_element; use crate::editor_runtime::attachment_upload::dispatch_editor_upload_request; use crate::editor_runtime::block_hover_state::HoveredBlockState; use crate::editor_runtime::block_menu_document::{ @@ -18,7 +19,6 @@ use crate::editor_runtime::block_menu_document::{ use crate::editor_runtime::command_sync::sync_persisted_editor_command; use crate::editor_runtime::persistence::persisted_document_identity; use crate::editor_runtime::slash_actions::SlashActionKind; -use crate::editor_root_element; pub(crate) fn prosemirror_node_size(node: &Value) -> Option { match node.get("type").and_then(Value::as_str) { @@ -282,9 +282,10 @@ pub(crate) fn run_slash_action( let _ = editor.focus(); editor.insert_table(4, 3, false) } - SlashActionKind::Mindmap => { - editor.insert_content(TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), None) - } + SlashActionKind::Mindmap => editor.insert_content( + TiptapContent::json(mindmap_paragraph_node(next_mindmap_id)), + None, + ), SlashActionKind::Image => { dispatch_editor_upload_request("image", "image/*")?; return Ok("已打开图片上传"); @@ -441,10 +442,7 @@ pub(crate) fn run_block_turn_into_folded_heading_action( }) } -pub(crate) fn top_level_block_is_page( - editor: TiptapEditorHandle, - block_index: usize, -) -> bool { +pub(crate) fn top_level_block_is_page(editor: TiptapEditorHandle, block_index: usize) -> bool { editor .get_json() .ok() diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/bridge_events.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/bridge_events.rs index 782a3135..88cd5c58 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/bridge_events.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/bridge_events.rs @@ -3,18 +3,26 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -pub(crate) const RUNTIME_NAME: &str = "8123-leptos-tiptap-runtime"; -pub(crate) const RUNTIME_VERSION: &str = "1.1.0"; +pub(crate) const RUNTIME_NAME: &str = "mnote-tiptap-island-runtime"; +pub(crate) const RUNTIME_VERSION: &str = "1.2.0"; pub(crate) const PROTOCOL: &str = "mnote.leptos_tiptap.bridge.v1"; pub(crate) const BLOCK_DELTA_EVENT: &str = "mnote:editor:block-delta"; +/// 稳定产品事件前缀(Phase 3);spike 前缀短期双发兼容。 +pub(crate) const STABLE_EVENT_PREFIX: &str = "mnote:tiptap-island"; +pub(crate) const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike"; pub(crate) const COMMAND_EVENT: &str = "mnote:leptos-tiptap-spike:command"; pub(crate) const HEIGHT_EVENT: &str = "mnote:leptos-tiptap-spike:height"; -pub(crate) const EVENT_PREFIX: &str = "mnote:leptos-tiptap-spike"; pub(crate) const READY_EVENT: &str = "mnote:leptos-tiptap-spike:ready"; pub(crate) const CHANGE_EVENT: &str = "mnote:leptos-tiptap-spike:change"; pub(crate) const STATE_EVENT: &str = "mnote:leptos-tiptap-spike:state"; pub(crate) const STATUS_EVENT: &str = "mnote:leptos-tiptap-spike:status"; pub(crate) const SELECTION_EVENT: &str = "mnote:leptos-tiptap-spike:selection"; +pub(crate) const STABLE_COMMAND_EVENT: &str = "mnote:tiptap-island:command"; +pub(crate) const STABLE_READY_EVENT: &str = "mnote:tiptap-island:ready"; +pub(crate) const STABLE_CHANGE_EVENT: &str = "mnote:tiptap-island:change"; +pub(crate) const STABLE_STATE_EVENT: &str = "mnote:tiptap-island:state"; +pub(crate) const STABLE_STATUS_EVENT: &str = "mnote:tiptap-island:status"; +pub(crate) const STABLE_SELECTION_EVENT: &str = "mnote:tiptap-island:selection"; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/button.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/button.rs index 0ffd2f6a..c9c2a56f 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/button.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/button.rs @@ -14,8 +14,8 @@ //! - B7: 块菜单按钮(popover trigger + focus) //! - B8: WASM gzipped 增量验证(本模块自身极小,不引入新 dep) -use leptos::prelude::*; use leptos::ev; +use leptos::prelude::*; /// 按钮语义变体 #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs index fd4f78a1..958fe18a 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs @@ -20,8 +20,8 @@ use std::collections::HashMap; use wasm_bindgen::{JsCast, JsValue}; use web_sys::{CustomEvent, CustomEventInit, EventTarget, HtmlElement, HtmlInputElement}; -use crate::editor_runtime::mount::next_mount_id; use crate::editor_runtime::icons::material_icon; +use crate::editor_runtime::mount::next_mount_id; thread_local! { static MOUNTED_MINDMAP_SHELLS: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs index 7dc96836..39abf768 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -1,22 +1,22 @@ pub(crate) mod attachment_links; pub(crate) mod attachment_upload; pub(crate) mod block_dnd; -pub(crate) mod button; -pub(crate) mod block_hover_state; -pub(crate) mod icons; pub(crate) mod block_handle_menu_view; +pub(crate) mod block_hover_state; pub(crate) mod block_menu_document; pub(crate) mod block_menu_overlay; pub(crate) mod block_transform; pub(crate) mod bridge_dispatch; pub(crate) mod bridge_events; +pub(crate) mod button; pub(crate) mod command_sync; -pub(crate) mod dismiss; pub(crate) mod content_layout; +pub(crate) mod dismiss; pub(crate) mod dom_events; pub(crate) mod dom_selection; pub(crate) mod editor_focus; pub(crate) mod history_safe_commands; +pub(crate) mod icons; pub(crate) mod mindmap_node_view; pub(crate) mod mount; pub(crate) mod overlays; diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mount.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mount.rs index ffc97b37..9e031d59 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mount.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mount.rs @@ -74,11 +74,8 @@ pub(crate) fn next_mount_id() -> u32 { pub(crate) fn set_runtime_mount_context(context: Option<(u32, EventTarget, RuntimeDeliveryMode)>) { RUNTIME_MOUNT_CONTEXT.with(|cell| { - *cell.borrow_mut() = context.map(|(id, target, mode)| RuntimeMountContext { - id, - target, - mode, - }); + *cell.borrow_mut() = + context.map(|(id, target, mode)| RuntimeMountContext { id, target, mode }); }); } diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs index 8759bb40..8e938081 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/overlays.rs @@ -622,7 +622,6 @@ pub(crate) fn try_close_block_menu_overlays( true } - /// 在 overlay 打开时注册 focus-trap 与 dismiss 层,在关闭时自动清理。 /// 这是 B7/B8 的 radix 化收口:将手写的 Escape / click-outside / tab 循环统一到此函数。 /// diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/runtime_bridge.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/runtime_bridge.rs index b1bdf620..11eafcd4 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/runtime_bridge.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/runtime_bridge.rs @@ -13,7 +13,9 @@ use crate::editor_runtime::bridge_dispatch::HostCommandKind; use crate::editor_runtime::bridge_events::{ BridgeEnvelope, ChangePayload, HostCommandEnvelope, HostStatusPayload, ReadyPayload, StatePayload, CHANGE_EVENT, COMMAND_EVENT, EVENT_PREFIX, PROTOCOL, READY_EVENT, RUNTIME_NAME, - RUNTIME_VERSION, SELECTION_EVENT, STATE_EVENT, STATUS_EVENT, + RUNTIME_VERSION, SELECTION_EVENT, STABLE_CHANGE_EVENT, STABLE_COMMAND_EVENT, + STABLE_READY_EVENT, STABLE_SELECTION_EVENT, STABLE_STATE_EVENT, + STABLE_STATUS_EVENT, STATE_EVENT, STATUS_EVENT, }; use crate::editor_runtime::dom_selection::SelectionPayload; @@ -100,22 +102,27 @@ where pub(crate) fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) { dispatch_custom_event_to_target(target, READY_EVENT, payload); + dispatch_custom_event_to_target(target, STABLE_READY_EVENT, payload); } pub(crate) fn dispatch_change_event_to_target(target: &EventTarget, payload: &ChangePayload) { dispatch_custom_event_to_target(target, CHANGE_EVENT, payload); + dispatch_custom_event_to_target(target, STABLE_CHANGE_EVENT, payload); } pub(crate) fn dispatch_state_event_to_target(target: &EventTarget, payload: &StatePayload) { dispatch_custom_event_to_target(target, STATE_EVENT, payload); + dispatch_custom_event_to_target(target, STABLE_STATE_EVENT, payload); } pub(crate) fn dispatch_status_event_to_target(target: &EventTarget, payload: &HostStatusPayload) { dispatch_custom_event_to_target(target, STATUS_EVENT, payload); + dispatch_custom_event_to_target(target, STABLE_STATUS_EVENT, payload); } pub(crate) fn dispatch_selection_event_to_target(target: &EventTarget, payload: &SelectionPayload) { dispatch_custom_event_to_target(target, SELECTION_EVENT, payload); + dispatch_custom_event_to_target(target, STABLE_SELECTION_EVENT, payload); } pub(crate) fn register_unmount_handle( diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/style.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/style.rs index 85204b35..6e8e56d1 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/style.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/style.rs @@ -1321,6 +1321,34 @@ pub(crate) const SPIKE_STYLE: &str = r#" pointer-events: auto; } +.block-drag-aux-panel { + position: absolute; + left: calc(100% + 8px); + top: 112px; + width: 236px; + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 10px; + background: #fff; + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.18); + z-index: 132; + color: #37352f; + pointer-events: auto; +} + +.block-drag-aux-panel > strong { + font-size: 13px; + line-height: 1.35; +} + +.block-drag-aux-panel > span { + color: #6b7280; + font-size: 12px; + line-height: 1.45; +} + .block-drag-menu-item[disabled] { color: #a1a1aa; cursor: default; @@ -2637,11 +2665,28 @@ pub(crate) const SPIKE_STYLE: &str = r#" border-top: 1px solid rgba(148, 163, 184, 0.26); } - .block-handle-shell, .block-drop-indicator { display: none; } + .block-handle-shell { + left: 4px; + width: 44px; + min-height: 44px; + padding: 2px; + } + + .block-handle-insert { + display: none; + } + + .block-handle-trigger { + width: 40px; + height: 40px; + border-radius: 8px; + cursor: pointer; + } + .app-shell-embedded .editor-surface { padding-left: 0; } diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 01462dad..6e411012 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -2338,12 +2338,14 @@ fn App(mount_options: MountOptions) -> impl IntoView { } }); }); - let preview_mouseup_handle = window_event_listener(ev::mouseup, move |event: MouseEvent| { - if event.button() == 1 || image_preview_pan.try_get_untracked().flatten().is_some() { - event.prevent_default(); - let _ = set_image_preview_pan.try_set(None); - } - }); + let preview_mouseup_handle = + window_event_listener(ev::mouseup, move |event: MouseEvent| { + if event.button() == 1 || image_preview_pan.try_get_untracked().flatten().is_some() + { + event.prevent_default(); + let _ = set_image_preview_pan.try_set(None); + } + }); on_cleanup(move || { drop(keydown_handle); drop(preview_mousemove_handle); diff --git a/scripts/TESTING_REFERENCE.md b/scripts/TESTING_REFERENCE.md index d42b242b..150b5284 100644 --- a/scripts/TESTING_REFERENCE.md +++ b/scripts/TESTING_REFERENCE.md @@ -1,5 +1,63 @@ # mnote 测试参考 +## ⚡ 浏览器测试快速启动(AI agent 专用) + +> **执行浏览器验证前,先确认此节,避免浪费时间。** + +### 1. 检查服务是否已运行 +```bash +ss -ltnp 'sport = :3000' +# 有输出 = 服务已运行,跳到步骤 3 +# 无输出 = 服务未运行,继续步骤 2 +``` + +### 2. 启动服务(后台模式) +```bash +nohup npm run dev:hot > /tmp/mnote-dev.log 2>&1 & +echo $! +# 等待 5-10 秒后检查日志 +sleep 8 && grep -E 'gateway 已启动|Running' /tmp/mnote-dev.log +``` +**关键点:** +- 启动命令是 `npm run dev:hot`(不是 desktop:hot) +- `dev:hot` 默认启用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1` +- 使用 cargo-watch 自动重编译 +- 端口固定为 3000 + +### 3. 浏览器验证步骤(Paseo) +```bash +# 1. 新建标签页 +paseo_browser_new_tab url=http://localhost:3000/auth + +# 2. 等待页面加载 +paseo_browser_wait url=localhost:3000 timeoutMs=15000 + +# 3. 截图(如果返回 "tab not painted",等 3 秒重试) +paseo_browser_screenshot + +# 4. 快速登录(snapshot 找到 @e5 即"测试账号快速登录"按钮) +paseo_browser_click ref=@e5 + +# 5. 等待进入工作区 +paseo_browser_wait url=localhost:3000 text=工作区 + +# 6. 截图 +paseo_browser_screenshot +``` + +### 4. 测试账号 +- 邮箱:`mnote.e2e@example.com` +- 密码:`MnoteE2E123!` +- 用户名:`mnote-e2e` + +### 5. 常见问题 +| 问题 | 原因 | 解决 | +|------|------|------| +| ERR_CONNECTION_REFUSED | 服务器未启动 | 先运行 `npm run dev:hot` | +| tab not painted | 页面未渲染完成 | 等待 3 秒后重试截图 | +| Paseo 终端无输出 | 前台进程阻塞 | 用 `nohup ... &` 后台启动 | +| `desktop:hot` vs `dev:hot` | 启动命令不同 | 浏览器测试用 `dev:hot` | + 这份文档面向 `/mnt/Data1T/mnote/scripts` 目录下的现有测试脚本,目标不是重新设计测试体系,而是把当前已经在用的 smoke、回归脚本、截图取证和人工复核方式整理成一套可执行参考。 当前结论先说在前面: @@ -36,12 +94,12 @@ node scripts/task490-runtime-surfaces-smoke.js - 入口与认证:`task114-rust-web-gateway-entry-smoke.js`、`task159-auth-entry-smoke.js`、`task097-homepage-entry-smoke.js` - Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`;cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js` - leptos-tiptap runtime surface:`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js` -- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`。`task452` 只覆盖普通本地搜索、settings API、tag/backlink API 和旧 local-index UI 不复活;资料库问答、PDF/Office/image source ingestion 与引用回跳改跑 WeKnora / provider-neutral knowledge-rag 脚本。 -- LightRAG / Knowledge RAG 资料库:`task781-knowledge-bases-registry-smoke.js`、`task785-weknora-create-kb-folder-ingest-search-smoke.js`、`task787-weknora-default-lightrag-legacy-smoke.js` 覆盖默认 provider、知识库列表、摄取、搜索、引用桥接与 legacy LightRAG guard。旧 `task529/530/531/534` LightRAG 脚本只作为 legacy fallback / 历史对照,不进入默认回归。旧 OpenHub 专项资料库 smoke 已软归档到 `recycle/20260712-pi-ts-openhub-retirement/`;旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档;`/api/search/documents` 不作为 WeKnora、LightRAG legacy、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback。 +- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`。`task452` 只覆盖普通本地搜索、settings API、tag/backlink API 和旧 local-index UI 不复活;资料库问答、PDF/Office/image source ingestion 与引用回跳改跑 LightRAG / provider-neutral knowledge-rag 脚本。 +- LightRAG / Knowledge RAG 资料库:LightRAG 是默认 provider。资料源、摄取、source scope 与 watcher 改动优先跑 `task532-knowledge-rag-docx-ingestion-smoke.js`、`task533-knowledge-rag-source-watcher-sync-smoke.js`、`task534-knowledge-rag-source-management-scope-smoke.js`、`task538-knowledge-rag-source-scope-api-smoke.js`;引用和 Page AI final answer 改动补跑 `task529-knowledge-rag-citation-resource-tab-smoke.js`、`task530-knowledge-rag-page-ai-final-answer-smoke.js`;真实 Pi + LightRAG 服务可用时补跑 `task-pi-lab-real-lightrag-smoke.js`。WeKnora 仅是历史/备用 provider,不进入默认回归。旧 OpenHub 专项资料库 smoke 已软归档到 `recycle/20260712-pi-ts-openhub-retirement/`;旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档;`/api/search/documents` 不作为 LightRAG、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback。 - local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。 - tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js` - 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`;Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js` -- Page AI / agent history / ChatOnly:`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payload;opencode WebUI embed 改动补跑 `task763-page-ai-opencode-embed-smoke.js`,只验证真实登录、Page AI 抽屉、opencode iframe/host chrome 和 changed file/open/refresh DOM hook,不 mock 聊天成功;raw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js`;`task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js` +- Page AI / agent history / ChatOnly:`task502-page-ai-agent-selector-context-smoke.js` 覆盖 Page AI agent/context/target picker、skills source 和 run payload;raw local resource target 改动补跑 `task520-page-ai-raw-resource-target-smoke.js`;`task504-page-ai-history-agent-filter-smoke.js` 覆盖 Page AI 历史按 agent 过滤;ChatOnly / provider session 绑定改动优先跑 `task512-chatonly-doubao-sync-smoke.js`,跨 provider 同步补跑 `task513-chatonly-provider-sync-smoke.js`。`task763` / `task765` 是 OpenCode debug fallback 专项脚本,不属于默认回归,待 debug route 彻底移除时一并退役。 - Dev hot / OnlyOffice live bridge:Sidebar dev hot reload 入口改动补跑 `task514-sidebar-dev-hot-reload-gating-smoke.js`;OnlyOffice live bridge session / scope / HTTP 工具边界改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`;bridge session/token/queue/current/close 和 `docKey/pageOrigin` 元数据改动补跑 `task516-onlyoffice-bridge-multisession-browser-smoke.js`;bridge plugin index / `Asc.plugin` direct loop / plugin 元数据透传改动补跑 `task517-onlyoffice-bridge-plugin-direct-smoke.js`;真实 ONLYOFFICE iframe / DocumentServer session、同文档双 tab、resource scope 和非 dry-run 写入落点改动补跑 `task518-onlyoffice-real-iframe-session-scope-smoke.js`;Page AI 真实 UI 选择 Office target 并冻结 live bridge session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js` - local resource lifecycle API:`task437-local-folder-asset-trash-lifecycle-smoke.js`,现在只验证 `local-file:*` delete / restore / purge,不再依赖普通 `.txt` 是否出现在 UI 文件树。 @@ -52,6 +110,8 @@ node scripts/task490-runtime-surfaces-smoke.js - 旧 Convex 兼容 smoke 已软删除到 `recycle/20260527-legacy-cleanup/convex/scripts/`,包括 `task163`、`task174`、`task175`、`task427`、`task428`、`task429`、`task430`、`task431`、`task432`、`task433`、`task434`。 - `task179-tree-create-delete-no-reload-smoke.js`:覆盖 `/tree` debug shell。`/tree` 已是显式 debug/internal 边界,脚本默认阻断;只有设置 `MNOTE_ALLOW_DEBUG_TREE_SMOKE=1` 时才可执行。 - `task123-rust-web-tree-live-stream-consumer-smoke.js`:只证明 `/api/tree/events` SSE fallback 仍可用。当前 realtime 主链是 WebSocket push + SSE fallback;验证主链时优先使用 446 / 447 / 448,只有排查 fallback 时再跑 123。 +- WeKnora 默认 provider 脚本(`task544`、`task769`、`task772`、`task777`、`task781`、`task783`、`task784`、`task785`、`task787`、`task788`、`task789`、`task790`、`task79x`)已退役;它们与 LightRAG 默认 provider 主线相反。 +- Hermes/ACP/Reasonix 页面 AI 主流程 smoke 已退役;只保留 `task-hermes-page-ai-retirement-guard.js` 防止旧 runtime 复活。`task762-page-ai-board-first-smoke.js` 也已退役。 - 旧 `task019`、`task021`、`task022` 这类早期 UI regression 脚本已软删除到 `recycle/scripts/retired-ui-regressions/`,只作为历史对照;后续默认不要用于当前 Rust SSR / local-first 主路径验收。 ### 0.3 其他 current 候选脚本 @@ -120,9 +180,8 @@ node scripts/task490-runtime-surfaces-smoke.js 代表脚本: - `task119-rust-web-wolai-visual-regression-smoke.js` -- `task129-wolai-aline-baseline-smoke.js` -- `task130` 到 `task137` 一系列 `wolai-aline-*` -- `task160-wolai-page-settings-shell-smoke.js` +- 已跟踪的对标基线:`task118-rust-web-wolai-ui-parity-smoke.js`、`task119-rust-web-wolai-visual-regression-smoke.js` +- `task128`、`task129` 到 `task137`、`task160`、`task162` 是 gitignored 的本地历史取证脚本,不属于默认回归。 - 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 Pi Lab / opencode / Page AI smoke 与 `task-page-block-ai-tools-smoke.js`。Hermes 相关 smoke 只作为 legacy guard / 历史对照。 这一层最重要的原则不是“文案命中”,而是: @@ -191,9 +250,9 @@ node scripts/task097-homepage-entry-smoke.js - tree / sidebar 改动:`task112`、`task120`、`task122` - document shell / island:`task110`、`task121` - local-first:优先使用 `task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task455-local-folder-mindmap-clean-smoke.js` -- 页面设置:`task160`、`task164-page-options-visible-effect-smoke.js` +- 页面设置:`task164-page-options-visible-effect-smoke.js` - 双栏:`task165` -- AI:优先使用 Pi Lab / opencode / Page AI 当前主线 smoke、`task-page-block-ai-tools-smoke.js` 和 `task-hermes-page-ai-retirement-guard.js` 这类 legacy guard;Hermes 页面 AI smoke 只作为历史对照。Page AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js`、`task504-page-ai-history-agent-filter-smoke.js`、`task512-chatonly-doubao-sync-smoke.js`、`task513-chatonly-provider-sync-smoke.js`;OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`、`task516-onlyoffice-bridge-multisession-browser-smoke.js`、`task517-onlyoffice-bridge-plugin-direct-smoke.js`、`task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照 +- AI:优先使用 Pi Lab / Page AI 当前主线 smoke、`task-page-block-ai-tools-smoke.js` 和 `task-hermes-page-ai-retirement-guard.js` 这类 legacy guard;其余 Hermes 页面 AI 主流程 smoke 已退役。Page AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js`、`task504-page-ai-history-agent-filter-smoke.js`、`task512-chatonly-doubao-sync-smoke.js`、`task513-chatonly-provider-sync-smoke.js`;OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`、`task516-onlyoffice-bridge-multisession-browser-smoke.js`、`task517-onlyoffice-bridge-plugin-direct-smoke.js`、`task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照 原则: diff --git a/scripts/mnote-vault-cli.js b/scripts/mnote-vault-cli.js new file mode 100644 index 00000000..377ebc06 --- /dev/null +++ b/scripts/mnote-vault-cli.js @@ -0,0 +1,236 @@ +#!/usr/bin/env node +/** + * Multi-agent CLI for the shared AI password book (unique strategy). + * + * Same backend as Pi tools / HTTP: + * list | get | resolve + * + * Auth: cookie session to mnote-web (default http://127.0.0.1:3000). + * export MNOTE_BASE_URL=http://127.0.0.1:3000 + * export MNOTE_COOKIE='mnote_session=...' # or paste from browser + * + * Examples: + * node scripts/mnote-vault-cli.js list + * node scripts/mnote-vault-cli.js get --id cred_xxx + * node scripts/mnote-vault-cli.js resolve --id cred_xxx --field password + * + * Never print secrets to logs other than resolve stdout value line when --raw. + * Default resolve prints JSON including value for tool piping; use --raw for plain value only. + */ + +const BASE = (process.env.MNOTE_BASE_URL || process.env.MNOTE_URL || 'http://127.0.0.1:3000').replace( + /\/$/, + '' +); +const COOKIE = process.env.MNOTE_COOKIE || process.env.MNOTE_SESSION_COOKIE || ''; + +function usage() { + console.error(`Usage: + mnote-vault-cli.js auth-e2e # print export MNOTE_COOKIE=... for local mnote-web + mnote-vault-cli.js list [--status active|deleted] + mnote-vault-cli.js get --id + mnote-vault-cli.js resolve --id --field password|apikey|token [--raw] + mnote-vault-cli.js login --id [--force] + mnote-vault-cli.js session --id --cookie [--source human_bridge] + +Env: MNOTE_BASE_URL, MNOTE_COOKIE + auth-e2e uses MNOTE_E2E_EMAIL / MNOTE_E2E_PASSWORD (defaults: mnote.e2e@example.com / MnoteE2E123!) + +Steady-state: auth-e2e once → list (optional) → login (reuse session). +Human Cloudflare: browser then "session" write-back.`); + process.exit(2); +} + +function parseArgs(argv) { + const out = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if ( + a === '--id' || + a === '--field' || + a === '--status' || + a === '--cookie' || + a === '--source' || + a === '--expires' + ) { + out[a.slice(2)] = argv[++i]; + } else if (a === '--raw' || a === '--force') { + out[a.slice(2)] = true; + } else if (a.startsWith('--')) { + console.error('Unknown flag', a); + usage(); + } else { + out._.push(a); + } + } + return out; +} + +async function api(method, path, body) { + const headers = { + Accept: 'application/json', + }; + if (COOKIE) headers.Cookie = COOKIE; + if (body != null) { + headers['Content-Type'] = 'application/json'; + } + const res = await fetch(BASE + path, { + method, + headers, + body: body != null ? JSON.stringify(body) : undefined, + }); + let data = null; + try { + data = await res.json(); + } catch { + data = null; + } + if (!res.ok || (data && data.ok === false)) { + const msg = + (data && (data.message || (data.error && data.error.message))) || + `HTTP ${res.status}`; + const code = (data && data.code) || res.status; + const err = new Error(String(msg)); + err.code = code; + err.payload = data; + throw err; + } + return data && data.result != null ? data.result : data; +} + +async function authE2e() { + const email = + process.env.MNOTE_E2E_EMAIL || 'mnote.e2e@example.com'; + const password = process.env.MNOTE_E2E_PASSWORD || 'MnoteE2E123!'; + const body = { + action: 'auth:signIn', + args: { + provider: 'password', + params: { + password, + flow: 'signIn', + account: email, + email, + name: email.includes('@') ? email.split('@')[0] : email, + }, + }, + }; + const res = await fetch(BASE + '/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + }); + const setCookies = res.headers.getSetCookie + ? res.headers.getSetCookie() + : []; + // Node <18 fallback: raw get may only return one + const raw = res.headers.raw ? res.headers.raw()['set-cookie'] || [] : setCookies; + const parts = []; + for (const sc of raw) { + const first = String(sc).split(';')[0].trim(); + if (first) parts.push(first); + } + if (!parts.length) { + const text = await res.text(); + throw new Error( + `auth-e2e failed HTTP ${res.status}: no Set-Cookie (${text.slice(0, 120)})` + ); + } + const cookieHeader = parts.join('; '); + // Print only export line for eval/source + console.log(`export MNOTE_COOKIE=${JSON.stringify(cookieHeader)}`); + console.error('[mnote-vault-cli] auth-e2e ok; eval the export line (do not paste cookie into chat)'); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const cmd = args._[0]; + if (!cmd) usage(); + + if (cmd === 'auth-e2e') { + await authE2e(); + return; + } + + if (!COOKIE) { + console.error( + 'Warning: MNOTE_COOKIE empty — run: eval $(node scripts/mnote-vault-cli.js auth-e2e)' + ); + } + + if (cmd === 'list') { + const status = args.status || 'active'; + const q = new URLSearchParams({ status }); + const result = await api('GET', `/api/vault/ai/list?${q}`); + console.log(JSON.stringify(result, null, 2)); + return; + } + if (cmd === 'get') { + if (!args.id) usage(); + const result = await api('GET', `/api/vault/ai/items/${encodeURIComponent(args.id)}`); + console.log(JSON.stringify(result, null, 2)); + return; + } + if (cmd === 'resolve') { + if (!args.id || !args.field) usage(); + const result = await api( + 'POST', + `/api/vault/ai/items/${encodeURIComponent(args.id)}/resolve`, + { field: args.field } + ); + if (args.raw) { + if (result && result.resolved && result.value != null) { + process.stdout.write(String(result.value)); + if (!String(result.value).endsWith('\n')) process.stdout.write('\n'); + } else { + console.error( + result && result.transcriptHint + ? result.transcriptHint + : 'not resolved' + ); + process.exit(1); + } + return; + } + console.log(JSON.stringify(result, null, 2)); + return; + } + if (cmd === 'login') { + if (!args.id) usage(); + const result = await api( + 'POST', + `/api/vault/ai/items/${encodeURIComponent(args.id)}/login`, + { forceRefresh: !!args.force } + ); + // Redact cookie in default print? Agent needs cookie — print full JSON; warn stderr. + if (result && result.cookieHeader) { + console.error('[mnote-vault-cli] cookieHeader present (do not paste into chat)'); + } + console.log(JSON.stringify(result, null, 2)); + if (result && result.ok === false && result.code === 'vault_login_human_required') { + process.exit(3); + } + return; + } + if (cmd === 'session') { + if (!args.id || !args.cookie) usage(); + const result = await api( + 'POST', + `/api/vault/ai/items/${encodeURIComponent(args.id)}/session`, + { + cookieHeader: args.cookie, + expiresAt: args.expires || undefined, + source: args.source || 'human_bridge', + } + ); + console.log(JSON.stringify(result, null, 2)); + return; + } + usage(); +} + +main().catch((err) => { + console.error(err.message || String(err)); + if (err.code) console.error('code:', err.code); + process.exit(1); +}); diff --git a/scripts/qa-block-handle-menu.js b/scripts/qa-block-handle-menu.js index 97620023..08d1bfa1 100644 --- a/scripts/qa-block-handle-menu.js +++ b/scripts/qa-block-handle-menu.js @@ -678,7 +678,9 @@ async function main() { result.pass = result.failures.length === 0; } catch (err) { result.steps.push({ step: "exception", status: "fail", message: String(err) }); - await screenshot("99-error").catch(() => {}); + await page + .screenshot({ path: path.join(SCREENSHOT_DIR, "99-error.png"), fullPage: false }) + .catch(() => {}); } finally { await browser.close(); } diff --git a/scripts/run-s2-s7-smokes.js b/scripts/run-s2-s7-smokes.js new file mode 100644 index 00000000..b39bca01 --- /dev/null +++ b/scripts/run-s2-s7-smokes.js @@ -0,0 +1,183 @@ +#!/usr/bin/env node +// Start mnote-web (mock Pi) on a free port and run S2–S7 acceptance smokes. +// Does not touch unrelated workspace state. Prefer existing BASE if set. + +"use strict"; + +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const REPO = path.resolve(__dirname, ".."); +const RUST_DIR = path.join(REPO, "rust"); +const BINARY = path.join(RUST_DIR, "target/debug/mnote-web"); +const TIMEOUT_MS = Number(process.env.MNOTE_S2_S7_TIMEOUT_MS || 120_000); +const EXTERNAL_BASE = (process.env.MNOTE_PI_LAB_BASE || process.env.MNOTE_S2_BASE || "").replace( + /\/+$/, + "", +); + +const SMOKES = [ + "task-s2-skills-mcp-delete-no-ghost-smoke.js", + "task-s3-directory-grants-allowed-roots-smoke.js", + "task-s4-pi-abort-idempotent-smoke.js", + "task-s5-rag-health-summary-filetree-smoke.js", + "task-s6-pi-resume-history-hydrate-smoke.js", + "task-s7-agent-target-dirty-guard-smoke.js", +]; + +function findFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("无法分配测试端口"))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +async function waitForGateway(baseUrl) { + const deadline = Date.now() + TIMEOUT_MS; + let lastError = null; + while (Date.now() < deadline) { + try { + const response = await fetch(`${baseUrl}/api/gateway/health`, { + redirect: "manual", + cache: "no-store", + }); + if (response.ok && response.headers.get("x-mnote-web-owner") === "mnote-web") { + return; + } + lastError = new Error(`gateway health: ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((r) => setTimeout(r, 300)); + } + throw lastError || new Error("gateway health 未就绪"); +} + +function startGateway(port, controlPlaneDb) { + const env = { + ...process.env, + MNOTE_WEB_ALLOW_DEV_FIXTURES: "1", + MNOTE_WEB_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, + MNOTE_CONTROL_PLANE_BACKEND: "libsql-local", + MNOTE_TURSO_LOCAL_PATH: controlPlaneDb, + MNOTE_PAGE_AI_PI_LAB: "1", + MNOTE_PAGE_AI_PI_LAB_RUNTIME: "mock", + MNOTE_PAGE_AI_PI_WARMUP: "0", + MNOTE_PAGE_AI_PI_WARMUP_SEND: "0", + RUST_LOG: process.env.RUST_LOG || "warn", + }; + const cmd = fs.existsSync(BINARY) + ? [BINARY] + : ["cargo", "run", "-q", "-p", "mnote-web", "--bin", "mnote-web"]; + const child = spawn(cmd[0], cmd.slice(1), { + cwd: RUST_DIR, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + if (stderr.length > 20_000) stderr = stderr.slice(-20_000); + }); + child.stdout.on("data", () => {}); + child._s2s7Stderr = () => stderr; + return child; +} + +function runSmoke(script, base) { + return new Promise((resolve, reject) => { + const env = { + ...process.env, + MNOTE_PI_LAB_BASE: base, + MNOTE_S2_BASE: base, + MNOTE_S3_BASE: base, + MNOTE_S4_BASE: base, + MNOTE_S5_BASE: base, + MNOTE_S6_BASE: base, + MNOTE_S7_BASE: base, + }; + const child = spawn(process.execPath, [path.join(__dirname, script)], { + cwd: REPO, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let out = ""; + let err = ""; + child.stdout.on("data", (c) => { + out += c.toString(); + process.stdout.write(c); + }); + child.stderr.on("data", (c) => { + err += c.toString(); + process.stderr.write(c); + }); + child.on("exit", (code) => { + if (code === 0) resolve({ script, out }); + else reject(new Error(`${script} exited ${code}\n${err || out}`)); + }); + }); +} + +async function main() { + let child = null; + let base = EXTERNAL_BASE; + const tmpDbDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s2s7-cp-")); + const controlPlaneDb = path.join(tmpDbDir, "control-plane-s2s7.db"); + + try { + if (!base) { + const port = await findFreePort(); + base = `http://127.0.0.1:${port}`; + console.log(`\n🚀 starting mnote-web mock on ${base}\n`); + child = startGateway(port, controlPlaneDb); + child.on("exit", (code, signal) => { + if (code && code !== 0) { + console.error(`mnote-web exited code=${code} signal=${signal}`); + console.error(child._s2s7Stderr && child._s2s7Stderr()); + } + }); + await waitForGateway(base); + console.log(" gateway ready\n"); + } else { + console.log(`\n🔗 using external BASE ${base}\n`); + await waitForGateway(base); + } + + const results = []; + for (const script of SMOKES) { + console.log(`── ${script} ──`); + await runSmoke(script, base); + results.push(script); + } + console.log(`\n✅ S2–S7 all passed (${results.length}):\n ${results.join("\n ")}\n`); + } finally { + if (child && !child.killed) { + child.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 500)); + try { + child.kill("SIGKILL"); + } catch { + /* ignore */ + } + } + } +} + +main().catch((err) => { + console.error("\n❌ S2–S7 runner failed:", err.message || err); + process.exit(1); +}); diff --git a/scripts/task-ai-management-control-plane-static-smoke.js b/scripts/task-ai-management-control-plane-static-smoke.js index 3eb4fa66..e767a482 100644 --- a/scripts/task-ai-management-control-plane-static-smoke.js +++ b/scripts/task-ai-management-control-plane-static-smoke.js @@ -390,9 +390,9 @@ check( check( "admin settings requires admin authorization", - aiSettingsRs.includes("ensure_admin(&context)?") && - aiSettingsRs.includes("is_local_access_policy_admin_context"), - "GET/PUT 都复用现有管理员鉴权" + aiSettingsRs.includes("ensure_admin(&state, &context)?") && + aiSettingsRs.includes("current_actor_is_local_admin(state, context)"), + "GET/PUT 都复用 session 优先的管理员鉴权" ); check( diff --git a/scripts/task-hermes-page-ai-agent-skill-smoke.js b/scripts/task-hermes-page-ai-agent-skill-smoke.js deleted file mode 100644 index 207b3255..00000000 --- a/scripts/task-hermes-page-ai-agent-skill-smoke.js +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, -} = require("./tree-shell-smoke-helpers"); - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-agent-skill-${suffix}`; - const runId = `run_agent_skill_${suffix}`; - const createdIds = []; - const sessionBodies = []; - const runBodies = []; - const activeProfileBodies = []; - const memorySaveBodies = []; - const skillToggleBodies = []; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - - try { - await page.route("**/api/ai-agent/run", async (route) => { - throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); - }); - await page.route("**/api/hermes/client/profiles/active", async (route) => { - activeProfileBodies.push(JSON.parse(route.request().postData() || "{}")); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, profile: { name: "chemist", active: true } }), - }); - }); - await page.route("**/api/hermes/client/profiles", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - profiles: [ - { name: "default", active: true, model: "gpt-5", gateway: "openai", alias: "Default" }, - { name: "chemist", active: false, model: "gpt-5", gateway: "openai", alias: "Chemist" }, - ], - }), - }); - }); - await page.route("**/api/hermes/client/profile-memory**", async (route) => { - if (route.request().method() === "POST") { - memorySaveBodies.push(JSON.parse(route.request().postData() || "{}")); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true }), - }); - return; - } - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - memory: "长期记忆", - user: "用户偏好", - soul: "原始人格", - memory_mtime: Date.now(), - user_mtime: Date.now(), - soul_mtime: Date.now(), - }), - }); - }); - await page.route("**/api/hermes/client/skills**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - categories: [ - { - name: "writing", - description: "Writing helpers", - skills: [ - { - name: "ai-writing-detection", - description: "检测 AI 写作痕迹", - enabled: false, - source: "local", - }, - ], - }, - ], - archived: [], - }), - }); - }); - await page.route("**/api/hermes/client/capabilities**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }), - }); - }); - await page.route("**/api/hermes/client/skills/toggle", async (route) => { - skillToggleBodies.push(JSON.parse(route.request().postData() || "{}")); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true }), - }); - }); - await page.route("**/api/hermes/client/sessions", async (route) => { - const body = JSON.parse(route.request().postData() || "{}"); - sessionBodies.push(body); - const profile = body.profile || "default"; - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId: `mnote_${profile}_${suffix}`, - title: "当前页问答", - profile, - traceId: `trace_session_${suffix}`, - persistence: "hermes_on_first_run", - }), - }); - }); - await page.route("**/api/hermes/client/sessions/**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - session: { - sessionId: `mnote_chemist_${suffix}`, - profile: "chemist", - messages: [], - }, - }), - }); - }); - await page.route("**/api/hermes/client/runs", async (route) => { - runBodies.push(JSON.parse(route.request().postData() || "{}")); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, runId, sessionId: `mnote_chemist_${suffix}` }), - }); - }); - await page.route(`**/api/hermes/client/events/${runId}`, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: - `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: `mnote_chemist_${suffix}`, delta: "Agent " })}\n\n` + - `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: `mnote_chemist_${suffix}`, output: "Agent response" })}\n\n`, - }); - }); - - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - await page.goto( - `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, - { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, - ); - - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-profile-select]").selectOption("chemist", { timeout: UI_TIMEOUT_MS }); - await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-page-ai-profile") === "chemist", null, { - timeout: UI_TIMEOUT_MS, - }); - - await page.locator('[data-page-ai-memory-editor="soul"]').fill(`SOUL-${suffix}`, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-memory-save="soul"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-page-ai-memory-saved") === "soul", null, { - timeout: UI_TIMEOUT_MS, - }); - - await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-skill-search]").fill("writing", { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-skill-toggle="ai-writing-detection"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "ai-writing-detection", - null, - { timeout: UI_TIMEOUT_MS }, - ); - - await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-input]").fill(`请使用 chemist agent 总结 ${title}`, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Agent response"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - - assert(activeProfileBodies.some((body) => body.name === "chemist"), "选择 agent/profile 必须调用 Hermes active profile API"); - assert(memorySaveBodies.some((body) => body.section === "soul" && body.content === `SOUL-${suffix}`), "SOUL.md 保存必须调用 Hermes memory API"); - assert( - skillToggleBodies.some((body) => body.name === "ai-writing-detection" && body.enabled === true), - "skill 启用必须调用 Hermes skills toggle API", - ); - assert(runBodies.some((body) => body.profile === "chemist"), "Hermes run 必须携带当前 agent/profile"); - assert(sessionBodies.some((body) => body.profile === "chemist"), "切换 agent 后新 Hermes session 必须携带 profile"); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - profile: "chemist", - memorySectionsSaved: memorySaveBodies.map((body) => body.section), - skillsToggled: skillToggleBodies.map((body) => body.name), - runProfiles: runBodies.map((body) => body.profile), - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-artifact-smoke.js b/scripts/task-hermes-page-ai-artifact-smoke.js deleted file mode 100644 index 2600ee5b..00000000 --- a/scripts/task-hermes-page-ai-artifact-smoke.js +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, - requestJson, -} = require("./tree-shell-smoke-helpers"); - -async function callArtifact(request, target, suffix, toolName, args) { - return requestJson(request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { - toolName, - workspaceId: target.workspaceId, - documentId: target.documentId, - actorId: "smoke-user", - sessionId: `mnote_smoke_artifact_${suffix}`, - runId: `run_smoke_artifact_${suffix}`, - toolCallId: `call_${toolName.replace(/\W+/g, "_")}_${suffix}`, - traceId: `trace_smoke_artifact_${suffix}`, - idempotencyKey: `idem_${toolName.replace(/\W+/g, "_")}_${suffix}`, - dryRun: false, - capabilityScope: ["artifact.write"], - args, - }, - }); -} - -function assertArtifactReferenceEdge(edgesPayload, sourceDocumentId, artifactDocumentId, artifactType) { - const edges = Array.isArray(edgesPayload?.result?.edges) ? edgesPayload.result.edges : []; - const edge = edges.find( - (candidate) => - candidate && - candidate.fromNodeId === sourceDocumentId && - candidate.toNodeId === artifactDocumentId && - candidate.metadata?.kind === "ai_artifact_reference", - ); - assert(edge, `未查询到 ${artifactDocumentId} 的 ai_artifact_reference edge`); - assert.equal(edge.edgeType, "source_of", `${artifactDocumentId} reference edge 类型不对`); - assert.equal(edge.metadata.artifactType, artifactType, `${artifactDocumentId} artifactType 不对`); - assert.equal(edge.metadata.projectionOnlyGroup, "AI Artifacts", `${artifactDocumentId} projection group 不对`); -} - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-artifact-${suffix}`; - const quickSessionId = `mnote_smoke_artifact_quick_${suffix}`; - let quickRunIndex = 0; - const quickRunBodies = []; - const createdIds = []; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - const page = await context.newPage(); - - try { - await page.route("**/api/hermes/tools/mnote/call", async (route) => { - throw new Error(`页面 AI artifact 快捷入口不应直连 mnote tool route: ${route.request().url()}`); - }); - await page.route("**/api/hermes/client/sessions", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId: quickSessionId, - title: "当前页问答", - traceId: `trace_quick_session_${suffix}`, - }), - }); - }); - await page.route(`**/api/hermes/client/sessions/${quickSessionId}`, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId: quickSessionId, - session: { sessionId: quickSessionId, messages: [] }, - }), - }); - }); - await page.route("**/api/hermes/client/runs", async (route) => { - quickRunIndex += 1; - quickRunBodies.push(JSON.parse(route.request().postData() || "{}")); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId: quickSessionId, - runId: `run_quick_artifact_${suffix}_${quickRunIndex}`, - traceId: `trace_quick_artifact_${suffix}_${quickRunIndex}`, - }), - }); - }); - await page.route("**/api/hermes/client/events/**", async (route) => { - const runId = route.request().url().split("/").pop(); - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: - `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: quickSessionId, delta: "artifact intent queued" })}\n\n` + - `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: quickSessionId, output: "artifact intent queued" })}\n\n`, - }); - }); - - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - - const summary = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", { - summary: `摘要 ${suffix}`, - }); - const summaryRetry = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", { - summary: `摘要 ${suffix}`, - }); - const aiNote = await callArtifact(context.request, target, suffix, "mnote.artifact.create_ai_note", { - content: `AI Note ${suffix}`, - }); - const aiNoteSecond = await callArtifact(context.request, target, `${suffix}_second`, "mnote.artifact.create_ai_note", { - content: `AI Note second ${suffix}`, - }); - const artifactDocumentIds = Array.from( - new Set([ - summary.result.artifactDocumentId, - aiNote.result.artifactDocumentId, - aiNoteSecond.result.artifactDocumentId, - ]), - ); - createdIds.push(...artifactDocumentIds); - - for (const result of [summary, aiNote, aiNoteSecond]) { - assert.equal(result.ok, true, `${result.toolName} 应成功`); - assert.equal(result.audit.effect, "write", `${result.toolName} audit 应为 write`); - assert.equal(result.result.commandName, "tree.node.create", `${result.toolName} 必须走 tree.node.create`); - assert.equal(result.audit.commandId, result.result.commandId, `${result.toolName} audit commandId 必须指向 Rust commandId`); - assert(result.result.artifactDocumentId, `${result.toolName} 缺少 artifactDocumentId`); - assert.equal(result.result.referenceEdge?.from, target.documentId, `${result.toolName} reference edge from 不对`); - assert.equal(result.result.referenceEdge?.to, result.result.artifactDocumentId, `${result.toolName} reference edge to 不对`); - assert.equal(result.result.referenceEdge?.kind, "ai_artifact_reference", `${result.toolName} reference edge kind 不对`); - assert(result.result.artifacts?.commandLog, `${result.toolName} 缺少 commandLog artifact`); - assert(result.result.artifacts?.domainEvent, `${result.toolName} 缺少 domainEvent artifact`); - assert.equal( - result.result.artifacts.domainEvent.eventType, - "tree.node.created", - `${result.toolName} domain event 类型不对`, - ); - } - assert.equal(summaryRetry.result.commandId, summary.result.commandId, "summary 同 idempotencyKey 重试必须返回同一 commandId"); - assert.equal( - summaryRetry.result.artifactDocumentId, - summary.result.artifactDocumentId, - "summary 同页面重试必须指向同一 summary document", - ); - assert.notEqual( - aiNoteSecond.result.artifactDocumentId, - aiNote.result.artifactDocumentId, - "ai_note 多次创建必须生成独立 artifact document", - ); - - const edgePayload = await requestJson( - context.request, - `/api/kernel/edges?workspaceId=${encodeURIComponent(target.workspaceId)}&nodeId=${encodeURIComponent(target.documentId)}`, - { method: "GET" }, - ); - assertArtifactReferenceEdge(edgePayload, target.documentId, summary.result.artifactDocumentId, "summary"); - assertArtifactReferenceEdge(edgePayload, target.documentId, aiNote.result.artifactDocumentId, "ai_note"); - assertArtifactReferenceEdge(edgePayload, target.documentId, aiNoteSecond.result.artifactDocumentId, "ai_note"); - - const fileProjection = await requestJson( - context.request, - `/api/tree/projections/file?workspaceId=${encodeURIComponent(target.workspaceId)}&rootNodeId=${encodeURIComponent(target.documentId)}&depth=2`, - { method: "GET" }, - ); - const projectionItems = Array.isArray(fileProjection?.result?.items) ? fileProjection.result.items : []; - const aiArtifactsMeta = fileProjection?.result?.meta?.aiArtifacts || {}; - assert.equal(aiArtifactsMeta.title, "AI Artifacts", "AI Artifacts projection meta 标题不对"); - assert.equal(aiArtifactsMeta.projectionOnly, true, "AI Artifacts 必须是 projection-only 分组"); - assert.equal(aiArtifactsMeta.kernelNodeId, null, "AI Artifacts 不应是真实 kernel node"); - assert( - !projectionItems.some((item) => item && item.nodeId === "AI Artifacts"), - "AI Artifacts 不应出现在 projection items 中作为真实 node", - ); - for (const artifactDocumentId of artifactDocumentIds) { - assert( - aiArtifactsMeta.artifactDocumentIds?.includes(artifactDocumentId), - `AI Artifacts projection meta 缺少 ${artifactDocumentId}`, - ); - } - - const auditPayload = await requestJson( - context.request, - `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(`trace_smoke_artifact_${suffix}`)}`, - { - method: "GET", - headers: { "x-mnote-actor-id": "smoke-user" }, - }, - ); - const auditEvents = Array.isArray(auditPayload.events) ? auditPayload.events : []; - for (const result of [summary, aiNote]) { - assert( - auditEvents.some( - (event) => - event && - event.phase === "completed" && - event.toolCallId === result.toolCallId && - event.audit?.commandId === result.result.commandId, - ), - `${result.toolName} audit 未串到 Hermes tool call 和 Rust command`, - ); - } - - await page.goto( - `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, - { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, - ); - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-intent="create-summary"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("artifact intent queued"), - undefined, - { timeout: UI_TIMEOUT_MS }, - ); - await page.locator('[data-page-ai-intent="create-ai-note"]').click({ timeout: UI_TIMEOUT_MS }); - const deadline = Date.now() + UI_TIMEOUT_MS; - while (quickRunBodies.length < 2 && Date.now() < deadline) { - await page.waitForTimeout(100); - } - assert.equal(quickRunBodies.length, 2, "两个 artifact 快捷入口都必须发送 Hermes run intent"); - assert( - quickRunBodies[0].message.includes("mnote.artifact.create_summary"), - "创建 Summary 快捷入口必须发送 Hermes artifact summary intent", - ); - assert( - quickRunBodies[1].message.includes("mnote.artifact.create_ai_note"), - "创建 AI Note 快捷入口必须发送 Hermes artifact ai_note intent", - ); - assert( - quickRunBodies.every((body) => body.documentId === target.documentId && body.sessionId === quickSessionId), - "artifact 快捷入口必须携带当前页面 documentId 与 Hermes sessionId", - ); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - summaryArtifactType: summary.result.artifactType, - aiNoteArtifactType: aiNote.result.artifactType, - summaryCommand: summary.result.commandName, - aiNoteCommand: aiNote.result.commandName, - summaryArtifactDocumentId: summary.result.artifactDocumentId, - aiNoteArtifactDocumentId: aiNote.result.artifactDocumentId, - aiNoteSecondArtifactDocumentId: aiNoteSecond.result.artifactDocumentId, - referenceEdgeCount: edgePayload.result.edges.length, - aiArtifactsMeta, - auditEventCount: auditEvents.length, - quickIntentMessages: quickRunBodies.map((body) => body.message), - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-audit-smoke.js b/scripts/task-hermes-page-ai-audit-smoke.js deleted file mode 100644 index 63f0f561..00000000 --- a/scripts/task-hermes-page-ai-audit-smoke.js +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, - requestJson, -} = require("./tree-shell-smoke-helpers"); - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-audit-${suffix}`; - const marker = `TEST-HERMES-AI-AUDIT-${suffix}`; - const createdIds = []; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - const page = await context.newPage(); - - try { - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - - const readTraceId = `trace_audit_read_${suffix}`; - const writeTraceId = `trace_audit_write_${suffix}`; - const deniedTraceId = `trace_audit_denied_${suffix}`; - const readToolCallId = `call_audit_read_${suffix}`; - const writeToolCallId = `call_audit_write_${suffix}`; - const deniedToolCallId = `call_audit_denied_${suffix}`; - - const read = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { - toolName: "mnote.page.get", - workspaceId: target.workspaceId, - documentId: target.documentId, - actorId: "smoke-user", - sessionId: `mnote_audit_${suffix}`, - runId: `run_audit_read_${suffix}`, - toolCallId: readToolCallId, - traceId: readTraceId, - capabilityScope: ["page.read"], - }, - }); - assert.equal(read.audit.effect, "read", "读工具 audit effect 应为 read"); - - const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { - toolName: "mnote.page.save", - workspaceId: target.workspaceId, - documentId: target.documentId, - actorId: "smoke-user", - sessionId: `mnote_audit_${suffix}`, - runId: `run_audit_write_${suffix}`, - toolCallId: writeToolCallId, - traceId: writeTraceId, - idempotencyKey: `idem_audit_write_${suffix}`, - dryRun: false, - capabilityScope: ["page.write"], - args: { - mode: "replace", - content: [ - { - id: `audit_block_${suffix}`, - type: "paragraph", - content: [{ type: "text", text: marker }], - }, - ], - }, - }, - }); - assert.equal(write.audit.effect, "write", "写工具 audit effect 应为 write"); - assert(write.audit.commandId, "写工具 audit 必须包含 commandId"); - - const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-mnote-actor-id": "smoke-user", - "x-mnote-workspace-id": "ws_denied", - }, - data: JSON.stringify({ - toolName: "mnote.page.save", - workspaceId: target.workspaceId, - documentId: target.documentId, - actorId: "smoke-user", - sessionId: `mnote_audit_${suffix}`, - runId: `run_audit_denied_${suffix}`, - toolCallId: deniedToolCallId, - traceId: deniedTraceId, - idempotencyKey: `idem_audit_denied_${suffix}`, - dryRun: false, - capabilityScope: ["page.write"], - args: { - mode: "replace", - content: [ - { - id: `audit_denied_block_${suffix}`, - type: "paragraph", - content: [{ type: "text", text: marker }], - }, - ], - }, - }), - }); - const deniedText = await deniedResponse.text(); - assert.equal(deniedResponse.status(), 403, "权限失败 smoke 应返回 workspace_context_conflict"); - assert(!deniedText.includes(title), "权限失败响应不应包含页面标题"); - assert(!deniedText.includes(marker), "权限失败响应不应包含正文 marker"); - - const readAudit = await requestJson( - context.request, - `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(readTraceId)}`, - { - method: "GET", - headers: { "x-mnote-actor-id": "smoke-user" }, - }, - ); - const writeAudit = await requestJson( - context.request, - `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}`, - { - method: "GET", - headers: { "x-mnote-actor-id": "smoke-user" }, - }, - ); - const writePersistedAudit = await requestJson( - context.request, - `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}&persistedOnly=true`, - { - method: "GET", - headers: { "x-mnote-actor-id": "smoke-user" }, - }, - ); - const deniedPersistedAudit = await requestJson( - context.request, - `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(deniedTraceId)}&persistedOnly=true`, - { - method: "GET", - headers: { "x-mnote-actor-id": "smoke-user" }, - }, - ); - - const readPhases = new Set((readAudit.events || []).map((event) => event.phase)); - const writePhases = new Set((writeAudit.events || []).map((event) => event.phase)); - const writePersistedPhases = new Set((writePersistedAudit.events || []).map((event) => event.phase)); - const deniedPersistedPhases = new Set((deniedPersistedAudit.events || []).map((event) => event.phase)); - assert(readPhases.has("started"), "读工具 audit 缺少 started"); - assert(readPhases.has("completed"), "读工具 audit 缺少 completed"); - assert(writePhases.has("started"), "写工具 audit 缺少 started"); - assert(writePhases.has("completed"), "写工具 audit 缺少 completed"); - assert(writePersistedPhases.has("started"), "持久化写工具 audit 缺少 started"); - assert(writePersistedPhases.has("completed"), "持久化写工具 audit 缺少 completed"); - assert(deniedPersistedPhases.has("failed"), "持久化权限失败 audit 缺少 failed"); - assert( - (writeAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId), - "写工具 audit 查询结果未串到 Rust commandId", - ); - assert( - (writePersistedAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId), - "持久化写工具 audit 查询结果未串到 Rust commandId", - ); - assert(!JSON.stringify(writeAudit).includes(marker), "audit 查询结果不应包含完整正文内容"); - assert(!JSON.stringify(writePersistedAudit).includes(marker), "持久化 audit 不应包含完整正文内容"); - assert(!JSON.stringify(deniedPersistedAudit).includes(marker), "权限失败持久化 audit 不应包含完整正文内容"); - assert(!JSON.stringify(deniedPersistedAudit).includes(title), "权限失败持久化 audit 不应包含页面标题"); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - readTraceId, - writeTraceId, - deniedTraceId, - writeCommandId: write.audit.commandId, - readPhases: Array.from(readPhases), - writePhases: Array.from(writePhases), - writePersistedPhases: Array.from(writePersistedPhases), - deniedPersistedPhases: Array.from(deniedPersistedPhases), - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-baseline-smoke.js b/scripts/task-hermes-page-ai-baseline-smoke.js deleted file mode 100644 index f6a84e7b..00000000 --- a/scripts/task-hermes-page-ai-baseline-smoke.js +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, -} = require("./tree-shell-smoke-helpers"); - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-baseline-${suffix}`; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - const captured = []; - const createdIds = []; - - page.on("request", (request) => { - const url = request.url(); - if (url.includes("/api/ai-agent/run") || url.includes("/api/hermes/client/")) { - captured.push({ - phase: "request", - method: request.method(), - url, - postData: request.postData() || "", - }); - } - }); - page.on("response", async (response) => { - const url = response.url(); - if (!url.includes("/api/ai-agent/run") && !url.includes("/api/hermes/client/")) { - return; - } - let body = ""; - try { - body = (await response.text()).slice(0, 1600); - } catch { - body = ""; - } - captured.push({ - phase: "response", - status: response.status(), - url, - body, - headers: response.headers(), - }); - }); - - try { - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - - await page.goto( - `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, - { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, - ); - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-input]").fill("概括当前页面标题和第一段", { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').length > 0, - null, - { timeout: UI_TIMEOUT_MS }, - ); - - const aiRequests = captured.filter((entry) => entry.phase === "request"); - assert(aiRequests.length > 0, "未捕获页面 AI 主请求"); - const firstRequest = aiRequests[0]; - const legacyRunHit = aiRequests.some((entry) => entry.url.includes("/api/ai-agent/run")); - const hermesClientHit = aiRequests.some((entry) => entry.url.includes("/api/hermes/client/")); - const providerHermes502 = captured.some( - (entry) => - entry.phase === "response" && - entry.status === 502 && - /provider|ai_provider_bridge_unavailable|hermes/i.test(entry.body || ""), - ); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - title, - documentId: target.documentId, - workspaceId: target.workspaceId, - firstRequestUrl: firstRequest.url, - legacyRunHit, - hermesClientHit, - providerHermes502, - captured, - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-mobile-smoke.js b/scripts/task-hermes-page-ai-mobile-smoke.js deleted file mode 100644 index d15f1502..00000000 --- a/scripts/task-hermes-page-ai-mobile-smoke.js +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, -} = require("./tree-shell-smoke-helpers"); - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-mobile-${suffix}`; - const sessionId = `mnote_mobile_${suffix}`; - const runId = `run_mobile_${suffix}`; - const createdIds = []; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ - viewport: { width: 390, height: 844 }, - }); - const page = await context.newPage(); - - try { - await page.route("**/api/ai-agent/run", async (route) => { - throw new Error(`移动端页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); - }); - await page.route("**/api/hermes/client/sessions", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - title: "移动端问答", - traceId: "trace_mobile", - persistence: "hermes_on_first_run", - }), - }); - }); - await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - traceId: "trace_mobile_restore", - session: { - sessionId, - messages: [{ role: "assistant", content: "Mobile restored" }], - }, - }), - }); - }); - await page.route("**/api/hermes/client/runs", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - runId, - events: [], - traceId: "trace_mobile", - }), - }); - }); - await page.route(`**/api/hermes/client/events/${runId}`, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: - `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Mobile " })}\n\n` + - `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Mobile response" })}\n\n`, - }); - }); - - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - await page.goto( - `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, - { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, - ); - - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - const metrics = await page.locator('[data-testid="wolai-page-ai-drawer"]').evaluate((drawer) => { - const panel = drawer.querySelector(".wolai-page-ai-panel"); - const drawerRect = drawer.getBoundingClientRect(); - const panelRect = panel ? panel.getBoundingClientRect() : drawerRect; - return { - viewportWidth: window.innerWidth, - documentWidth: document.documentElement.scrollWidth, - drawerLeft: drawerRect.left, - drawerRight: drawerRect.right, - panelLeft: panelRect.left, - panelRight: panelRect.right, - }; - }); - assert(metrics.drawerLeft >= 0, `drawer 左侧溢出: ${JSON.stringify(metrics)}`); - assert(metrics.drawerRight <= metrics.viewportWidth + 1, `drawer 右侧溢出: ${JSON.stringify(metrics)}`); - assert(metrics.panelLeft >= 0, `panel 左侧溢出: ${JSON.stringify(metrics)}`); - assert(metrics.panelRight <= metrics.viewportWidth + 1, `panel 右侧溢出: ${JSON.stringify(metrics)}`); - assert(metrics.documentWidth <= metrics.viewportWidth + 1, `页面出现横向滚动: ${JSON.stringify(metrics)}`); - - await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Mobile response"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - sessionId, - runId, - metrics, - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-queue-smoke.js b/scripts/task-hermes-page-ai-queue-smoke.js deleted file mode 100644 index d56cd261..00000000 --- a/scripts/task-hermes-page-ai-queue-smoke.js +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, -} = require("./tree-shell-smoke-helpers"); - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-queue-${suffix}`; - const sessionId = `mnote_queue_${suffix}`; - const runId = `run_queue_${suffix}`; - const queueId = `queue_${suffix}`; - const createdIds = []; - const runBodies = []; - const cancelBodies = []; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - - try { - await page.route("**/api/ai-agent/run", async (route) => { - throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); - }); - await page.route("**/api/hermes/client/profiles", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, profiles: [{ name: "default", active: true, model: "gpt-5" }] }), - }); - }); - await page.route("**/api/hermes/client/profile-memory**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, memory: "", user: "", soul: "" }), - }); - }); - await page.route("**/api/hermes/client/skills**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, categories: [], archived: [] }), - }); - }); - await page.route("**/api/hermes/client/capabilities**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }), - }); - }); - await page.route("**/api/hermes/client/tools**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, tools: [] }), - }); - }); - await page.route("**/api/hermes/client/sessions", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, sessionId, title: "队列测试", profile: "default" }), - }); - }); - await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - session: { sessionId, profile: "default", messages: [] }, - runtime: { sessionId, runId, status: "running", queueLength: 0 }, - }), - }); - }); - await page.route("**/api/hermes/client/runs", async (route) => { - runBodies.push(JSON.parse(route.request().postData() || "{}")); - if (runBodies.length === 1) { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, runId, sessionId, traceId: `trace_run_${suffix}` }), - }); - return; - } - await route.fulfill({ - status: 202, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - queued: true, - queueId, - sessionId, - status: "queued", - queueLength: 1, - traceId: `trace_queue_${suffix}`, - }), - }); - }); - await page.route(`**/api/hermes/client/sessions/${sessionId}/queue/${queueId}`, async (route) => { - cancelBodies.push({ method: route.request().method(), url: route.request().url() }); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, sessionId, queueId, cancelled: true, queueLength: 0 }), - }); - }); - await page.route(`**/api/hermes/client/events/${runId}`, async () => { - // 保持第一个 run 处于 active 状态,让第二条输入进入队列。 - }); - - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - await page.goto( - `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, - { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, - ); - - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-input]").fill(`第一条 ${title}`, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await waitForCondition(() => runBodies.length === 1, UI_TIMEOUT_MS, "未捕获第一条 run 请求"); - - await page.locator("[data-page-ai-input]").fill(`第二条 ${title}`, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("已加入 Hermes 队列"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForFunction( - () => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("队列 1"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="runtime"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator(`[data-page-ai-queue-item="${queueId}"] [data-page-ai-action="cancel-queued-run"]`).click({ - timeout: UI_TIMEOUT_MS, - }); - await page.waitForFunction( - () => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("队列 1") === false, - null, - { timeout: UI_TIMEOUT_MS }, - ); - await waitForCondition(() => cancelBodies.length === 1, UI_TIMEOUT_MS, "未捕获取消 queued item 请求"); - - assert.equal(runBodies.length, 2, "第二条输入必须到达 mnote-web BFF"); - assert.equal(cancelBodies.length, 1, "取消 queued item 必须调用 mnote-web queue cancel API"); - assert.equal(cancelBodies[0].method, "DELETE", "取消 queued item 必须使用 DELETE"); - assert.equal(runBodies[0].sessionId, sessionId, "第一条 run 必须携带 Hermes sessionId"); - assert.equal(runBodies[1].sessionId, sessionId, "第二条 queued run 必须携带同一个 sessionId"); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - sessionId, - runId, - queueId, - runRequests: runBodies.length, - cancelRequests: cancelBodies.length, - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -async function waitForCondition(predicate, timeoutMs, message) { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - if (predicate()) return; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(message); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-runtime-controls-smoke.js b/scripts/task-hermes-page-ai-runtime-controls-smoke.js deleted file mode 100644 index a1dbf9a4..00000000 --- a/scripts/task-hermes-page-ai-runtime-controls-smoke.js +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, -} = require("./tree-shell-smoke-helpers"); - -const EXPECTED_SETTINGS_URL = - process.env.MNOTE_EXPECTED_HERMES_SETTINGS_URL || "http://127.0.0.1:3999/hermes/settings"; - -function deferred() { - let resolve; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-runtime-${suffix}`; - const sessionId = `mnote_runtime_${suffix}`; - const runId = `run_runtime_${suffix}`; - const createdIds = []; - const sessionBodies = []; - const runBodies = []; - const abortBodies = []; - const eventsRelease = deferred(); - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - - try { - await page.route("**/api/ai-agent/run", async (route) => { - throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); - }); - await page.route("**/api/hermes/client/profiles", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, profiles: [{ name: "default", active: true, model: "gpt-5" }] }), - }); - }); - await page.route("**/api/hermes/client/profile-memory**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, memory: "", user: "", soul: "" }), - }); - }); - await page.route("**/api/hermes/client/skills**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, categories: [], archived: [] }), - }); - }); - await page.route("**/api/hermes/client/capabilities**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, runtime: "mnote", categories: [], archived: [] }), - }); - }); - await page.route("**/api/hermes/client/sessions", async (route) => { - sessionBodies.push(JSON.parse(route.request().postData() || "{}")); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - title: "运行控制测试", - profile: "default", - traceId: `trace_session_${suffix}`, - }), - }); - }); - await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, session: { sessionId, profile: "default", messages: [] } }), - }); - }); - await page.route("**/api/hermes/client/runs", async (route) => { - runBodies.push(JSON.parse(route.request().postData() || "{}")); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, runId, sessionId, traceId: `trace_run_${suffix}` }), - }); - }); - await page.route(`**/api/hermes/client/events/${runId}`, async (route) => { - await eventsRelease.promise; - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: - `data: ${JSON.stringify({ event: "run.aborted", run_id: runId, session_id: sessionId })}\n\n` + - `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "SHOULD_NOT_APPEAR_AFTER_ABORT" })}\n\n`, - }); - }); - await page.route(`**/api/hermes/client/runs/${runId}/abort`, async (route) => { - abortBodies.push(JSON.parse(route.request().postData() || "{}")); - eventsRelease.resolve(); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - runId, - status: "aborted", - runtime: { sessionId, runId, status: "aborted" }, - events: [ - { event: "abort.started", runId }, - { event: "abort.completed", runId }, - ], - }), - }); - }); - - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - await page.goto( - `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, - { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, - ); - - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="runtime"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - (expected) => window.__mnoteHermesSettingsUrl === expected, - EXPECTED_SETTINGS_URL, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForFunction( - () => (document.querySelector("[data-page-ai-tool-list]")?.textContent || "").includes("mnote.page.save"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - const settingsDisabled = await page.locator('[data-page-ai-action="open-hermes-settings"]').isDisabled(); - assert.equal(settingsDisabled, false, "配置 Hermes upstream 后设置入口不应禁用"); - - await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-context-scope]").selectOption("options", { timeout: UI_TIMEOUT_MS }); - await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-input]").fill(`请只读取页面设置 ${title}`, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "running", - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.locator('[data-page-ai-action="stop-run"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "aborted", - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForTimeout(100); - const drawerTextAfterAbort = await page.locator('[data-testid="wolai-page-ai-drawer"]').textContent({ - timeout: UI_TIMEOUT_MS, - }); - assert( - !drawerTextAfterAbort.includes("SHOULD_NOT_APPEAR_AFTER_ABORT"), - "abort 后不应继续追加该 run 的 assistant delta", - ); - - assert(sessionBodies.some((body) => body.profile === "default"), "创建 session 必须携带 profile"); - assert(abortBodies.some((body) => body.reason === "page_ai_user_stop"), "停止 run 必须调用 Hermes abort API"); - assert(runBodies.length > 0, "必须捕获 run 请求"); - const runBody = runBodies[0]; - assert.equal(runBody.contextScope, "options", "run 请求必须携带 contextScope"); - assert.equal(runBody.pageContext.contextScope, "options", "pageContext 必须记录 contextScope"); - assert.equal(runBody.pageContext.documentBlocks, null, "页面设置 scope 不应携带正文 blocks"); - assert(runBody.pageContext.pageOptions, "页面设置 scope 必须携带 pageOptions"); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - runId, - settingsUrl: await page.evaluate(() => window.__mnoteHermesSettingsUrl), - runContextScope: runBody.contextScope, - abortCount: abortBodies.length, - }, - null, - 2, - ), - ); - } finally { - eventsRelease.resolve(); - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-smoke.js b/scripts/task-hermes-page-ai-smoke.js deleted file mode 100644 index e45923c2..00000000 --- a/scripts/task-hermes-page-ai-smoke.js +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, -} = require("./tree-shell-smoke-helpers"); - -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)) || ""; -} - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-smoke-${suffix}`; - const sessionId = `mnote_smoke_${suffix}`; - const runId = `run_smoke_${suffix}`; - let runRequestCount = 0; - let sessionDetailHits = 0; - const captured = []; - const createdIds = []; - const executablePath = resolveChromiumExecutablePath(); - const browser = await chromium.launch({ - headless: true, - ...(executablePath ? { executablePath } : {}), - }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - - try { - await page.route("**/api/ai-agent/run", async (route) => { - throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`); - }); - await page.route("**/api/hermes/client/gateway/health**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - gateway: { ok: true, status: "mocked", upstream: "http://127.0.0.1:8644" }, - profile: { name: "default", modelConfigured: true, apiKeyConfigured: true }, - suggestions: [], - }), - }); - }); - await page.route("**/api/hermes/client/tools**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, tools: [] }), - }); - }); - await page.route("**/api/hermes/client/profiles", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - active: "default", - profiles: [{ name: "default", label: "Default", modelConfigured: true, apiKeyConfigured: true }], - }), - }); - }); - await page.route("**/api/hermes/client/sessions", async (route) => { - captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" }); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - title: "当前页问答", - traceId: "trace_smoke", - persistence: "hermes_on_first_run", - }), - }); - }); - await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => { - sessionDetailHits += 1; - captured.push({ kind: "session-detail", method: route.request().method(), body: "" }); - assert.equal(route.request().method(), "POST", "刷新恢复必须调用 runtime resume 接口"); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - traceId: "trace_restore", - session: { - sessionId, - messages: [], - }, - runtime: { - sessionId, - runId, - status: "completed", - profile: "default", - documentId: "doc_smoke", - traceId: "trace_restore", - lastToolName: "mnote.page.get", - }, - }), - }); - }); - await page.route("**/api/hermes/client/runs", async (route) => { - runRequestCount += 1; - const currentRunId = `${runId}_${runRequestCount}`; - captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" }); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - runId: currentRunId, - events: [], - traceId: "trace_smoke", - }), - }); - }); - await page.route("**/api/hermes/client/events/**", async (route) => { - const currentRunId = route.request().url().split("/").pop() || runId; - captured.push({ kind: "events", method: route.request().method(), body: "" }); - const evidenceEvents = Array.from({ length: 24 }, (_, index) => { - const callId = `call_smoke_knowledge_rag_${index}`; - return ( - `data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.knowledge_rag.query", args: { query: `evidence ${index}` } })}\n\n` + - `data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: callId, name: "mnote.knowledge_rag.query", summary: `资料库 ${index}`, auditId: `audit_smoke_knowledge_rag_${index}` })}\n\n` - ); - }).join(""); - if (runRequestCount > 1) { - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: - `data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "Second " })}\n\n` + - `data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "response" })}\n\n` + - `data: ${JSON.stringify({ event: "run.completed", run_id: currentRunId, session_id: sessionId, output: "Second response" })}\n\n`, - }); - return; - } - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: - evidenceEvents + - `data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", args: { includeBody: false } })}\n\n` + - `data: ${JSON.stringify({ event: "tool.completed", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", summary: "读取当前页面", auditId: "audit_smoke_page_get" })}\n\n` + - `data: ${JSON.stringify({ event: "tool.started", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", args: { dryRun: true } })}\n\n` + - `data: ${JSON.stringify({ event: "tool.failed", run_id: currentRunId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\n\n` + - `data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "Smoke " })}\n\n` + - `data: ${JSON.stringify({ event: "message.delta", run_id: currentRunId, session_id: sessionId, delta: "response" })}\n\n` + - `data: ${JSON.stringify({ - event: "run.completed", - run_id: currentRunId, - session_id: sessionId, - output: "Smoke response", - agentAudit: { - eventId: "audit_smoke_changed_files", - rootUri: "file:///tmp/mnote-smoke", - diffSummary: "1 changed file(s)", - changedFiles: [ - { - path: "README.md", - changeType: "modified", - summary: "修改文件 size:10→20 lines:1→2", - }, - ], - }, - })}\n\n`, - }); - }); - - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - await page.goto( - `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`, - { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }, - ); - - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Smoke response"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForFunction( - () => (document.querySelector("[data-page-ai-last-tool]")?.textContent || "").includes("mnote.page.save"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - const toolCards = await page.$$eval("[data-page-ai-tool-card]", (cards) => - cards.map((card) => ({ - id: card.getAttribute("data-page-ai-tool-call-id"), - group: card.getAttribute("data-page-ai-tool-group"), - status: card.getAttribute("data-page-ai-tool-status"), - text: card.textContent || "", - })), - ); - assert.equal(toolCards.length, 1, `批量工具调用应折叠成 1 张工具组卡:${JSON.stringify(toolCards)}`); - assert(toolCards[0].text.includes("调用工具") && toolCards[0].text.includes("27 个"), `工具组摘要应显示调用数量:${JSON.stringify(toolCards)}`); - const toolItems = await page.$$eval("[data-page-ai-tool-item]", (items) => - items.map((item) => ({ - id: item.getAttribute("data-page-ai-tool-call-id"), - status: item.getAttribute("data-page-ai-tool-status"), - text: item.textContent || "", - })), - ); - assert(toolItems.length >= 27, `工具组展开内容应包含批量工具调用:${JSON.stringify(toolItems)}`); - assert( - toolItems.some((item) => item.status === "completed" && item.text.includes("call_smoke_page_get")), - `缺少 completed 工具项:${JSON.stringify(toolItems)}`, - ); - assert( - toolItems.some((item) => item.status === "failed" && item.text.includes("permission_denied")), - `缺少 failed 工具项:${JSON.stringify(toolItems)}`, - ); - assert( - toolItems.some((item) => item.status === "completed" && item.text.includes("agent.changed_files") && item.text.includes("README.md")), - `缺少 changed files 工具项:${JSON.stringify(toolItems)}`, - ); - const toolDetailsOpenByDefault = await page.$$eval("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details", (details) => details.map((node) => node.open)); - assert.equal(toolDetailsOpenByDefault.length, 1, "工具调用应共用一个 details 折叠容器"); - assert(toolDetailsOpenByDefault.every((open) => open === false), "工具调用组默认应折叠"); - const orderState = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => { - const toolGroup = node.querySelector("[data-page-ai-tool-card]"); - const assistant = Array.from(node.querySelectorAll(".wolai-page-ai-message--assistant")).find((item) => - (item.textContent || "").includes("Smoke response") - ); - return { - toolBeforeAssistant: Boolean(toolGroup && assistant && (toolGroup.compareDocumentPosition(assistant) & Node.DOCUMENT_POSITION_FOLLOWING)), - }; - }); - assert(orderState.toolBeforeAssistant, "工具调用组应显示在 AI 输出结果之前"); - await page.locator("[data-page-ai-tool-card] summary").first().click({ timeout: UI_TIMEOUT_MS }); - assert( - await page.locator("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details").first().evaluate((node) => node.open), - "点击工具组 summary 后应展开详情", - ); - const scrollStateBeforeFollowup = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => { - node.scrollTop = 0; - return { - scrollTop: node.scrollTop, - scrollHeight: node.scrollHeight, - clientHeight: node.clientHeight, - }; - }); - assert(scrollStateBeforeFollowup.scrollHeight > scrollStateBeforeFollowup.clientHeight, `批量工具调用应撑出滚动区:${JSON.stringify(scrollStateBeforeFollowup)}`); - await page.locator("[data-page-ai-input]").fill("继续补充一句", { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Second response"), - null, - { timeout: UI_TIMEOUT_MS }, - ); - const scrollStateAfterFollowup = await page.locator('[data-page-ai-panel="chat"] [data-page-ai-conversation]').evaluate((node) => ({ - scrollTop: node.scrollTop, - scrollHeight: node.scrollHeight, - clientHeight: node.clientHeight, - firstToolOpen: Boolean(node.querySelector("[data-page-ai-tool-card] > .wolai-page-ai-message-text > details")?.open), - })); - assert(scrollStateAfterFollowup.scrollTop <= 4, `用户已上滚时新输出不应强制贴底:${JSON.stringify(scrollStateAfterFollowup)}`); - assert(scrollStateAfterFollowup.firstToolOpen, "重渲染后应保留用户展开的工具调用详情"); - - const sessionRequest = captured.find((entry) => entry.kind === "session"); - const runRequest = captured.find((entry) => entry.kind === "run"); - const eventRequest = captured.find((entry) => entry.kind === "events"); - assert(sessionRequest, "未捕获 /api/hermes/client/sessions 请求"); - assert(runRequest, "未捕获 /api/hermes/client/runs 请求"); - assert(eventRequest, "未捕获 /api/hermes/client/events 请求"); - const runBody = JSON.parse(runRequest.body); - assert.equal(runBody.sessionId, sessionId, "run 请求必须携带 Hermes sessionId"); - assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId"); - assert(runBody.pageContext, "run 请求必须携带最小 pageContext"); - assert.equal(runBody.pageContext.documentBlocks, null, "run 请求不应直接携带页面正文 blocks"); - assert.equal(runBody.pageContext.subtree, null, "run 请求不应直接携带页面 subtree"); - assert.equal(runBody.pageContext.outline, null, "run 请求不应直接携带页面 outline"); - assert( - runBody.pageContext.contentAccess, - `run 请求必须声明正文访问方式:${JSON.stringify(runBody.pageContext)}`, - ); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - sessionId, - runId, - capturedKinds: captured.map((entry) => entry.kind), - sessionDetailHits, - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-title-options-smoke.js b/scripts/task-hermes-page-ai-title-options-smoke.js deleted file mode 100644 index 79bc7947..00000000 --- a/scripts/task-hermes-page-ai-title-options-smoke.js +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - openDocument, - openSectionView, - renameDocument, - requestJson, - UI_TIMEOUT_MS, -} = require("./tree-shell-smoke-helpers"); - -async function main() { - const suffix = Date.now().toString(36); - const originalTitle = `TEST-HERMES-AI-title-original-${suffix}`; - const nextTitle = `TEST-HERMES-AI-title-updated-${suffix}`; - const createdIds = []; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - const page = await context.newPage(); - - try { - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, originalTitle); - - const common = { - workspaceId: target.workspaceId, - documentId: target.documentId, - actorId: "smoke-user", - sessionId: `mnote_smoke_title_${suffix}`, - runId: `run_smoke_title_${suffix}`, - traceId: `trace_smoke_title_${suffix}`, - capabilityScope: ["page.write"], - dryRun: false, - }; - - const dryRunTitle = `TEST-HERMES-AI-title-dry-${suffix}`; - const titleDryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { - ...common, - dryRun: true, - toolName: "mnote.page.update_title", - toolCallId: `call_title_dry_${suffix}`, - idempotencyKey: `idem_title_dry_${suffix}`, - args: { title: dryRunTitle }, - }, - }); - assert.equal(titleDryRun.result.dryRun, true, "标题 dryRun 不应写入"); - const metaAfterDryRun = await requestJson( - context.request, - `/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`, - { method: "GET" }, - ); - assert(!JSON.stringify(metaAfterDryRun).includes(dryRunTitle), "标题 dryRun 后 meta 不应变化"); - - const titleResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { - ...common, - toolName: "mnote.page.update_title", - toolCallId: `call_title_${suffix}`, - idempotencyKey: `idem_title_${suffix}`, - args: { title: nextTitle }, - }, - }); - assert.equal(titleResult.result.commandName, "page.head.updateTitle", "标题必须走 page.head.updateTitle"); - assert.equal(titleResult.audit.commandId, titleResult.result.commandId, "标题 audit commandId 必须指向 Rust commandId"); - const titleRetry = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { - ...common, - toolName: "mnote.page.update_title", - toolCallId: `call_title_${suffix}`, - idempotencyKey: `idem_title_${suffix}`, - args: { title: nextTitle }, - }, - }); - assert.equal( - titleRetry.result.commandId, - titleResult.result.commandId, - "标题同一 idempotencyKey 重试必须返回同一个 commandId", - ); - - const optionsResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { - ...common, - toolName: "mnote.page.update_options", - toolCallId: `call_options_${suffix}`, - idempotencyKey: `idem_options_${suffix}`, - args: { options: { wideLayout: true, smallText: true, pageFont: "serif" } }, - }, - }); - assert.equal( - optionsResult.result.commandName, - "page.layout.updateOptions", - "页面设置必须走 page.layout.updateOptions", - ); - assert.equal( - optionsResult.audit.commandId, - optionsResult.result.commandId, - "页面设置 audit commandId 必须指向 Rust commandId", - ); - assert( - Array.isArray(optionsResult.result.ignoredOptions) && - optionsResult.result.ignoredOptions.includes("pageFont"), - "planned/ui_only 页面设置字段必须明确返回 ignoredOptions", - ); - assert( - Array.isArray(optionsResult.result.warnings) && - optionsResult.result.warnings.some((warning) => warning && warning.code === "page_option_not_wired"), - "planned/ui_only 页面设置字段必须明确返回 warning", - ); - - const meta = await requestJson( - context.request, - `/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`, - { method: "GET" }, - ); - assert(JSON.stringify(meta).includes(nextTitle), "meta 未读到 AI 更新后的标题"); - assert(JSON.stringify(meta).includes("wide"), "meta 未读到页面设置更新结果"); - - await openDocument(page, target.workspaceId, target.documentId); - await openSectionView(page); - await page.waitForFunction( - (title) => { - const input = document.querySelector('[data-page-title-input="true"]'); - const current = document.querySelector('[data-page-title-current="true"]'); - return (input && input.value === title) || (current && current.textContent.includes(title)); - }, - nextTitle, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForFunction( - ({ documentId, title }) => { - const selectors = [ - `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`, - `.tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"] > .tree-link > .tree-link-title`, - ]; - return selectors.some((selector) => { - const node = document.querySelector(selector); - return node && (node.textContent || "").includes(title); - }); - }, - { documentId: target.documentId, title: nextTitle }, - { timeout: UI_TIMEOUT_MS }, - ); - const uiState = await page.evaluate((documentId) => { - const shell = document.querySelector(".document-shell"); - const sessionKey = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || ""; - return { - titleInput: document.querySelector('[data-page-title-input="true"]')?.value || "", - currentTitle: document.querySelector('[data-page-title-current="true"]')?.textContent || "", - sidebarTitle: - document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`) - ?.textContent || "", - wideLayout: shell?.getAttribute("data-page-wide-layout") || "", - smallText: shell?.getAttribute("data-page-small-text") || "", - pageAiSessionOwner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "", - pageAiSessionKey: sessionKey, - pageAiSessionStorage: sessionKey && window.localStorage ? window.localStorage.getItem(sessionKey) || "" : "", - }; - }, target.documentId); - assert( - uiState.titleInput === nextTitle || uiState.currentTitle.includes(nextTitle), - `刷新后页面 UI 未显示 AI 更新标题: ${JSON.stringify(uiState)}`, - ); - assert(uiState.sidebarTitle.includes(nextTitle), `sidebar 未显示 AI 更新标题: ${JSON.stringify(uiState)}`); - assert.equal(uiState.wideLayout, "true", "刷新后 document-shell 未应用 wideLayout"); - assert.equal(uiState.smallText, "true", "刷新后 document-shell 未应用 smallText"); - - await page.locator('[data-testid="wolai-floating-ai"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => - document.documentElement.getAttribute("data-mnote-page-ai-session-owner") === "hermes" && - Boolean(document.documentElement.getAttribute("data-mnote-page-ai-session-key")), - undefined, - { timeout: UI_TIMEOUT_MS }, - ); - const aiSessionState = await page.evaluate(() => { - const key = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || ""; - return { - owner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "", - key, - stored: key && window.localStorage ? window.localStorage.getItem(key) || "" : "", - pageTitle: - document.querySelector('[data-page-title-input="true"]')?.value || - document.querySelector('[data-page-title-current="true"]')?.textContent || - "", - }; - }); - assert.equal(aiSessionState.owner, "hermes", "页面 AI session owner 必须是 Hermes"); - const storedSessionState = JSON.parse(aiSessionState.stored || "{}"); - assert(storedSessionState.activeSessionId, "mnote 本地只应保存 Hermes activeSessionId"); - assert(!aiSessionState.stored.includes(nextTitle), "mnote 本地 AI session 状态不应保存或驱动页面标题真相"); - assert(aiSessionState.pageTitle.includes(nextTitle), "页面标题真相必须仍来自页面 UI / Page Aggregate"); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - title: nextTitle, - titleCommand: titleResult.result.commandName, - optionsCommand: optionsResult.result.commandName, - ignoredOptions: optionsResult.result.ignoredOptions, - warningCodes: optionsResult.result.warnings.map((warning) => warning.code), - uiState, - aiSessionState, - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-tool-smoke.js b/scripts/task-hermes-page-ai-tool-smoke.js deleted file mode 100644 index f0d01f2c..00000000 --- a/scripts/task-hermes-page-ai-tool-smoke.js +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, - requestJson, -} = require("./tree-shell-smoke-helpers"); - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-tool-${suffix}`; - const createdIds = []; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - const page = await context.newPage(); - - try { - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - - const manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", { - method: "GET", - headers: { "x-mnote-actor-id": "smoke-user" }, - }); - const tools = manifest?.manifest?.tools || []; - assert(tools.some((tool) => tool.name === "mnote.page.get"), "manifest 缺少 mnote.page.get"); - - const call = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { - toolName: "mnote.page.get", - workspaceId: target.workspaceId, - documentId: target.documentId, - actorId: "smoke-user", - sessionId: `mnote_smoke_tool_${suffix}`, - runId: `run_smoke_tool_${suffix}`, - toolCallId: `call_smoke_tool_${suffix}`, - traceId: `trace_smoke_tool_${suffix}`, - capabilityScope: ["page.read"], - args: { - includeBody: true, - includeOptions: true, - includeBlocks: true, - }, - }, - }); - - assert.equal(call.ok, true, "tool call 应成功"); - assert.equal(call.toolName, "mnote.page.get", "toolName 不一致"); - assert.equal(call.toolCallId, `call_smoke_tool_${suffix}`, "toolCallId 不一致"); - assert.equal(call.result.documentId, target.documentId, "工具结果 documentId 不一致"); - assert.equal(call.result.workspaceId, target.workspaceId, "工具结果 workspaceId 不一致"); - assert(call.result.title, "工具结果缺少标题"); - assert(call.audit && call.audit.effect === "read", "工具结果缺少 read audit"); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - toolName: call.toolName, - title: call.result.title, - audit: call.audit, - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-hermes-page-ai-writeback-smoke.js b/scripts/task-hermes-page-ai-writeback-smoke.js deleted file mode 100644 index 02943ab1..00000000 --- a/scripts/task-hermes-page-ai-writeback-smoke.js +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const { chromium } = require("playwright"); -const { - BASE_URL, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - openDocument, - renameDocument, - requestJson, -} = require("./tree-shell-smoke-helpers"); - -async function assertPageAiPermissionFailureUi(page, target, suffix) { - const sessionId = `mnote_smoke_permission_${suffix}`; - const runId = `run_smoke_permission_${suffix}`; - const sessionRoute = "**/api/hermes/client/sessions"; - const sessionDetailRoute = `**/api/hermes/client/sessions/${sessionId}`; - const runsRoute = "**/api/hermes/client/runs"; - const eventsRoute = `**/api/hermes/client/events/${runId}`; - - await page.route(sessionRoute, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - title: "权限失败 UI 验证", - traceId: `trace_permission_${suffix}`, - }), - }); - }); - await page.route(sessionDetailRoute, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - sessionId, - session: { sessionId, messages: [] }, - }), - }); - }); - await page.route(runsRoute, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - upstream: { run_id: runId, status: "started" }, - traceId: `trace_permission_${suffix}`, - }), - }); - }); - await page.route(eventsRoute, async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: - `data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, tool: "mnote.page.save" })}\n\n` + - `data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, tool: "mnote.page.save", error: { code: "permission_denied" } })}\n\n` + - `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "permission_denied" })}\n\n`, - }); - }); - - try { - await openDocument(page, target.workspaceId, target.documentId); - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-input]").fill("权限失败 UI 验证", { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => { - const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || ""; - return text.includes("mnote.page.save") && text.includes("permission_denied"); - }, - null, - { timeout: UI_TIMEOUT_MS }, - ); - } finally { - await page.unroute(sessionRoute).catch(() => undefined); - await page.unroute(sessionDetailRoute).catch(() => undefined); - await page.unroute(runsRoute).catch(() => undefined); - await page.unroute(eventsRoute).catch(() => undefined); - } -} - -async function main() { - const suffix = Date.now().toString(36); - const title = `TEST-HERMES-AI-write-${suffix}`; - const marker = `TEST-HERMES-AI-WRITEBACK-${suffix}`; - const createdIds = []; - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - const page = await context.newPage(); - - try { - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - await renameDocument(context.request, target.workspaceId, target.documentId, title); - - const baseTool = { - toolName: "mnote.page.save", - workspaceId: target.workspaceId, - documentId: target.documentId, - actorId: "smoke-user", - sessionId: `mnote_smoke_write_${suffix}`, - runId: `run_smoke_write_${suffix}`, - toolCallId: `call_smoke_write_${suffix}`, - traceId: `trace_smoke_write_${suffix}`, - idempotencyKey: `idem_smoke_write_${suffix}`, - capabilityScope: ["page.write"], - args: { - mode: "replace", - content: [ - { - id: `block_${suffix}`, - type: "paragraph", - content: [{ type: "text", text: marker }], - }, - ], - }, - }; - - const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-mnote-actor-id": "smoke-user", - "x-mnote-workspace-id": "ws_denied", - }, - data: JSON.stringify({ - ...baseTool, - traceId: `trace_smoke_write_denied_${suffix}`, - toolCallId: `call_smoke_write_denied_${suffix}`, - idempotencyKey: `idem_smoke_write_denied_${suffix}`, - }), - }); - const deniedText = await deniedResponse.text(); - assert.equal(deniedResponse.status(), 403, "workspace 上下文冲突应返回 403"); - assert(deniedText.includes("workspace_context_conflict"), "权限失败应返回稳定 workspace_context_conflict"); - assert(!deniedText.includes(title), "权限失败响应不应泄露页面标题"); - assert(!deniedText.includes(marker), "权限失败响应不应泄露正文内容"); - await assertPageAiPermissionFailureUi(page, target, suffix); - - const dryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { ...baseTool, dryRun: true, idempotencyKey: `idem_smoke_write_dry_${suffix}` }, - }); - assert.equal(dryRun.result.dryRun, true, "dryRun 不应写入"); - const dryRunContent = await requestJson( - context.request, - `/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`, - { method: "GET" }, - ); - assert(!JSON.stringify(dryRunContent).includes(marker), "dryRun 后不应读到 AI 写入标记"); - - await openDocument(page, target.workspaceId, target.documentId); - await page - .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]') - .waitFor({ state: "attached", timeout: UI_TIMEOUT_MS }) - .catch(() => undefined); - - const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { ...baseTool, dryRun: false }, - }); - assert.equal(write.ok, true, "写入 tool call 应成功"); - assert.equal(write.audit.effect, "write", "写入 audit effect 应为 write"); - assert.equal(write.result.commandName, "page.body.save", "写入必须走 page.body.save"); - assert(write.result.commandId, "写入结果必须包含 Rust commandId"); - assert.equal(write.audit.commandId, write.result.commandId, "audit commandId 必须指向 Rust commandId"); - await page.waitForFunction((expected) => (document.body.textContent || "").includes(expected), marker, { - timeout: UI_TIMEOUT_MS, - }); - - const retry = await requestJson(context.request, "/api/hermes/tools/mnote/call", { - method: "POST", - headers: { "x-mnote-actor-id": "smoke-user" }, - data: { ...baseTool, dryRun: false }, - }); - assert.equal(retry.ok, true, "幂等重试 tool call 应成功"); - assert.equal(retry.result.commandId, write.result.commandId, "同一 idempotencyKey 重试必须返回同一个 commandId"); - assert.equal(retry.audit.commandId, write.audit.commandId, "同一 idempotencyKey 重试必须返回同一个 audit commandId"); - - const content = await requestJson( - context.request, - `/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`, - { method: "GET" }, - ); - assert(JSON.stringify(content).includes(marker), "刷新读取内容后未找到 AI 写入标记"); - - console.log( - JSON.stringify( - { - ok: true, - baseUrl: BASE_URL, - documentId: target.documentId, - workspaceId: target.workspaceId, - marker, - commandName: write.result.commandName, - audit: write.audit, - }, - null, - 2, - ), - ); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task-page-tree-expand-persist-regression.js b/scripts/task-page-tree-expand-persist-regression.js new file mode 100644 index 00000000..c2877037 --- /dev/null +++ b/scripts/task-page-tree-expand-persist-regression.js @@ -0,0 +1,515 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Regression: page-tree expand/collapse icons + persistence + no duplicate rows. + * Builds a liaibo-like nested local_folder tree, exercises expand/collapse/reload. + */ + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const { chromium } = require("playwright"); +const { buildControlPlaneTestEnv } = require("./lib/control-plane-test-env"); + +const TASK = "task-page-tree-expand-persist-regression"; +const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 60_000); +const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join(""); +const RESULT_DIR = path.join(__dirname, "..", "tmp", TASK); +const BINARY = + process.env.MNOTE_WEB_BIN || + path.join(__dirname, "..", "rust", "target", "debug", "mnote-web"); + +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 pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + if (Date.now() > deadline) { + reject(new Error(`server_not_ready: ${url}`)); + return; + } + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) { + resolve(); + return; + } + setTimeout(tick, 250); + }); + request.on("error", () => setTimeout(tick, 250)); + }; + tick(); + }); +} + +function writeTree(root) { + // Mirror user screenshot shape: root page-group + several sibling folders. + const space = path.join(root, "liaibo的个人空间"); + const mk = (p, content = null) => { + if (content == null) { + fs.mkdirSync(p, { recursive: true }); + return; + } + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content, "utf8"); + }; + mk(space); + mk(path.join(space, "liaibo的个人空间.md"), "# liaibo的个人空间\n"); + for (const name of ["知识", "项目", "工作资料", "日常", "剪藏", "个人"]) { + mk(path.join(space, name)); + mk(path.join(space, name, `${name}.md`), `# ${name}\n`); + } + // Nested under 个人 (matches screenshot after expand) + for (const name of ["正版软件备份", "个人发展", "杂记", "密码"]) { + mk(path.join(space, "个人", name)); + mk(path.join(space, "个人", name, `${name}.md`), `# ${name}\n`); + } + // Nested under 知识 + for (const name of ["有机化学", "文章"]) { + mk(path.join(space, "知识", name)); + mk(path.join(space, "知识", name, `${name}.md`), `# ${name}\n`); + } + return space; +} + +async function signUp(context, baseUrl, actorId) { + const response = await context.request.fetch(`${baseUrl}/api/auth`, { + method: "POST", + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + email: `${actorId}@example.com`, + username: actorId, + name: actorId, + password: TEST_PASSWORD, + flow: "signUp", + }, + }, + }, + }); + assert.ok(response.ok(), `signUp failed: ${response.status()} ${await response.text()}`); +} + +async function openDefaultLocalWorkspace(page, baseUrl) { + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + const createButton = page.locator('[data-testid="mnote-create-default-local-workspace"]').first(); + if (await createButton.isVisible({ timeout: 5000 }).catch(() => false)) { + await createButton.click({ timeout: UI_TIMEOUT_MS }); + await page.waitForURL((url) => url.searchParams.get("sourceKind") === "local_folder", { + timeout: UI_TIMEOUT_MS, + }); + } + const rootUri = + new URL(page.url()).searchParams.get("rootUri") || + (await page.evaluate(() => document.body.getAttribute("data-mnote-root-uri") || "")); + assert.ok(rootUri, "should open local_folder workspace"); + return { + rootUri, + workspaceId: new URL(page.url()).searchParams.get("workspaceId") || "", + }; +} + +function fileUrlToPath(rootUri) { + assert.ok(rootUri.startsWith("file://"), `unexpected rootUri ${rootUri}`); + return decodeURIComponent(rootUri.slice("file://".length)); +} + +async function reloadWorkspace(page, baseUrl, rootUri, workspaceId) { + const url = new URL(baseUrl); + if (workspaceId) url.searchParams.set("workspaceId", workspaceId); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", { + timeout: UI_TIMEOUT_MS, + }); + await page.waitForTimeout(800); +} + +function reportTree(page) { + return page.evaluate(() => { + function computeChevron(row) { + const toggle = row.querySelector("[data-rust-action='toggle']"); + if (!toggle) return null; + const before = window.getComputedStyle(toggle, "::before"); + const transform = before.transform || ""; + // matrix(0,1,-1,0,0,0) ~= rotate(90deg) + const rotated = + transform.includes("matrix(0, 1, -1, 0") || + transform.includes("matrix(0,1,-1,0") || + (transform.includes("rotate") && !transform.includes("rotate(0")); + return { transform, rotated }; + } + const rows = Array.from( + document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']") + ); + const byId = new Map(); + const details = rows.map((row) => { + const nodeId = row.getAttribute("data-node-id") || ""; + byId.set(nodeId, (byId.get(nodeId) || 0) + 1); + const node = row.closest(".tree-node"); + const children = node && node.querySelector(":scope > .tree-children"); + const toggle = row.querySelector("[data-rust-action='toggle']"); + const title = String((row.querySelector(".tree-link-title") || {}).textContent || "").trim(); + const chevron = computeChevron(row); + const collapsed = children ? children.classList.contains("tree-children--collapsed") : null; + const childCount = children ? children.children.length : 0; + const visuallyOpen = collapsed === false && childCount > 0; + const aria = row.getAttribute("aria-expanded"); + const dataExpanded = row.getAttribute("data-expanded"); + const iconSaysOpen = chevron ? chevron.rotated : null; + return { + nodeId, + title, + parentId: row.getAttribute("data-parent-id") || "", + depth: Number(row.getAttribute("data-depth") || 0), + aria, + dataExpanded, + toggleAria: toggle ? toggle.getAttribute("aria-expanded") : null, + toggleData: toggle ? toggle.getAttribute("data-expanded") : null, + collapsed, + childCount, + visuallyOpen, + iconSaysOpen, + desync: + toggle != null && + ((iconSaysOpen === true && !visuallyOpen) || + (iconSaysOpen === false && visuallyOpen) || + aria !== dataExpanded || + (aria === "true") !== visuallyOpen), + }; + }); + const dups = [...byId.entries()].filter(([, n]) => n > 1).map(([id, n]) => ({ id, n })); + const titles = details.map((d) => d.title); + const titleCounts = titles.reduce((m, t) => { + m[t] = (m[t] || 0) + 1; + return m; + }, {}); + // top-level (depth 0 or parent empty) duplicates by title are the screenshot bug + const topTitles = details.filter((d) => !d.parentId || d.depth === 0).map((d) => d.title); + const topTitleCounts = topTitles.reduce((m, t) => { + m[t] = (m[t] || 0) + 1; + return m; + }, {}); + const ls = {}; + for (const k of Object.keys(localStorage)) { + if (!k.includes("sidebarTreeViewState")) continue; + try { + ls[k.slice(-60)] = JSON.parse(localStorage.getItem(k) || "null"); + } catch { + ls[k.slice(-60)] = localStorage.getItem(k); + } + } + return { + href: location.href, + rowCount: rows.length, + dups, + topTitleCounts, + desync: details.filter((d) => d.desync), + openRows: details.filter((d) => d.visuallyOpen).map((d) => d.title), + sample: details, + markers: { + applied: document.documentElement.getAttribute("data-mnote-page-tree-view-state-applied"), + synced: document.documentElement.getAttribute("data-mnote-page-tree-expand-synced"), + restored: document.documentElement.getAttribute("data-mnote-page-tree-expansion-restored"), + saved: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-saved"), + loadErr: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-load-error"), + saveErr: document.documentElement.getAttribute("data-mnote-sidebar-tree-view-state-save-error"), + }, + ls, + }; + }); +} + +async function clickToggleByTitle(page, title) { + return page.evaluate((want) => { + const rows = Array.from( + document.querySelectorAll("#sidebar-tree-root .tree-row[data-shell-mode='page']") + ); + for (const row of rows) { + const t = String((row.querySelector(".tree-link-title") || {}).textContent || "").trim(); + if (t !== want) continue; + const btn = row.querySelector("[data-rust-action='toggle']"); + if (!btn) return { ok: false, reason: "no_toggle" }; + btn.click(); + return { ok: true, nodeId: row.getAttribute("data-node-id") }; + } + return { ok: false, reason: "not_found" }; + }, title); +} + +async function waitDesyncClear(page, timeoutMs = 3000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const r = await reportTree(page); + if (r.desync.length === 0) return r; + await page.waitForTimeout(100); + } + return reportTree(page); +} + +async function main() { + fs.mkdirSync(RESULT_DIR, { recursive: true }); + if (!fs.existsSync(BINARY)) { + throw new Error(`binary missing: ${BINARY} (build mnote-web first)`); + } + const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-expand-reg-")); + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const cpEnv = buildControlPlaneTestEnv(dataRoot); + const env = { + ...process.env, + ...cpEnv, + MNOTE_LOCAL_WORKSPACE_BASE_DIR: dataRoot, + MNOTE_WEB_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, + MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0", + RUST_LOG: process.env.RUST_LOG || "warn", + }; + const child = spawn(BINARY, [], { + cwd: path.join(__dirname, "..", "rust"), + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let serverLog = ""; + child.stdout.on("data", (d) => { + serverLog += d.toString(); + }); + child.stderr.on("data", (d) => { + serverLog += d.toString(); + }); + const browser = await chromium.launch({ + headless: true, + executablePath: resolveChromiumExecutablePath() || undefined, + }); + const results = { steps: [] }; + try { + await waitForHttpOk(`${baseUrl}/health`, 45_000); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const actorId = `expand-reg-${Date.now()}`; + await signUp(context, baseUrl, actorId); + const page = await context.newPage(); + const puts = []; + const gets = []; + page.on("response", async (res) => { + if (!res.url().includes("/api/tree/view-state")) return; + const method = res.request().method(); + let body = null; + try { + body = await res.json(); + } catch {} + const entry = { + method, + status: res.status(), + expandedIds: body && body.result && body.result.state && body.result.state.expandedIds, + }; + if (method === "PUT") puts.push(entry); + else gets.push(entry); + }); + + const { rootUri, workspaceId } = await openDefaultLocalWorkspace(page, baseUrl); + const rootPath = fileUrlToPath(rootUri); + // Seed liaibo-like tree into the default local workspace. + writeTree(rootPath); + await reloadWorkspace(page, baseUrl, rootUri, workspaceId); + await page.screenshot({ path: path.join(RESULT_DIR, "01-initial.png") }); + let r = await reportTree(page); + results.steps.push({ name: "initial", r }); + assert.equal(r.dups.length, 0, `initial dups: ${JSON.stringify(r.dups)}`); + assert.equal(r.desync.length, 0, `initial desync: ${JSON.stringify(r.desync)}`); + // Default: expandable rows closed + ▶ (ignore non-toggle leaves) + for (const d of r.sample.filter((x) => x.toggleAria != null)) { + assert.equal(d.visuallyOpen, false, `initial open ${d.title}`); + assert.equal(d.iconSaysOpen, false, `initial icon open ${d.title}`); + assert.equal(d.aria, "false", `initial aria ${d.title}`); + } + + // Expand root then 个人 + let click = await clickToggleByTitle(page, "liaibo的个人空间"); + assert.equal(click.ok, true, `expand root: ${JSON.stringify(click)}`); + await page.waitForTimeout(700); + click = await clickToggleByTitle(page, "个人"); + assert.equal(click.ok, true, `expand 个人: ${JSON.stringify(click)}`); + await page.waitForTimeout(900); + await page.waitForTimeout(700); + await page.screenshot({ path: path.join(RESULT_DIR, "02-expanded.png") }); + r = await waitDesyncClear(page); + results.steps.push({ name: "expanded", r, puts: puts.slice(-2) }); + assert.equal(r.dups.length, 0, `expanded dups: ${JSON.stringify(r.dups)}`); + assert.equal(r.desync.length, 0, `expanded desync: ${JSON.stringify(r.desync)}`); + assert.ok(r.openRows.includes("liaibo的个人空间"), "root should be open"); + assert.ok(r.openRows.includes("个人"), "个人 should be open"); + const personalChildren = r.sample.filter((d) => d.title === "正版软件备份"); + assert.equal(personalChildren.length, 1, `个人 children dup: ${JSON.stringify(personalChildren)}`); + + // Collapse 个人 only + click = await clickToggleByTitle(page, "个人"); + assert.equal(click.ok, true); + await page.waitForTimeout(500); + r = await waitDesyncClear(page); + results.steps.push({ name: "collapsed-personal", r }); + assert.equal(r.dups.length, 0, `collapse dups ${JSON.stringify(r.dups)}`); + assert.equal(r.desync.length, 0, `collapse desync ${JSON.stringify(r.desync)}`); + const personal = r.sample.find((d) => d.title === "个人"); + assert.ok(personal, "个人 row missing"); + assert.equal(personal.visuallyOpen, false, "个人 should be closed"); + assert.equal(personal.iconSaysOpen, false, "个人 icon should be ▶"); + assert.equal(personal.aria, "false"); + const titleCountsUnderRoot = {}; + for (const d of r.sample) { + if (d.depth === 1) titleCountsUnderRoot[d.title] = (titleCountsUnderRoot[d.title] || 0) + 1; + } + for (const [title, count] of Object.entries(titleCountsUnderRoot)) { + assert.equal(count, 1, `duplicate depth1 title ${title} count=${count}`); + } + + // Re-expand 个人, then reload — should restore open state + click = await clickToggleByTitle(page, "个人"); + assert.equal(click.ok, true); + await page.waitForTimeout(1000); + r = await reportTree(page); + const lsValues = Object.values(r.ls); + const hasLocal = + lsValues.some((v) => v && Array.isArray(v.expandedIds) && v.expandedIds.length > 0) || + puts.some((p) => p.status === 200); + assert.ok(hasLocal || puts.length > 0, `persist missing ls=${JSON.stringify(r.ls)} puts=${puts.length}`); + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", { + timeout: UI_TIMEOUT_MS, + }); + await page.waitForTimeout(2500); + await page.screenshot({ path: path.join(RESULT_DIR, "03-after-reload.png") }); + r = await waitDesyncClear(page, 5000); + results.steps.push({ name: "after-reload-open", r, gets: gets.slice(-4), puts: puts.slice(-2) }); + assert.equal(r.dups.length, 0, `reload dups: ${JSON.stringify(r.dups)}`); + assert.equal(r.desync.length, 0, `reload desync: ${JSON.stringify(r.desync)}`); + assert.ok( + r.openRows.includes("liaibo的个人空间") && r.openRows.includes("个人"), + `reload should restore expands, openRows=${JSON.stringify(r.openRows)} markers=${JSON.stringify(r.markers)} ls=${JSON.stringify(r.ls)}` + ); + // Nested children of 个人 must actually reappear after cascade hydrate. + const afterReloadPersonalChild = r.sample.filter((d) => d.title === "正版软件备份"); + assert.equal( + afterReloadPersonalChild.length, + 1, + `reload missing nested child: ${JSON.stringify(afterReloadPersonalChild)} sample=${JSON.stringify( + r.sample.map((d) => ({ t: d.title, d: d.depth, open: d.visuallyOpen, children: d.childCount })) + )}` + ); + const personalAfterReload = r.sample.find((d) => d.title === "个人"); + assert.ok(personalAfterReload && personalAfterReload.childCount >= 4, `个人 childCount after reload ${personalAfterReload?.childCount}`); + assert.equal(personalAfterReload.iconSaysOpen, true, "个人 icon should be ▼ after restore"); + const d1 = {}; + for (const d of r.sample) { + if (d.depth === 1) d1[d.title] = (d1[d.title] || 0) + 1; + } + for (const [title, count] of Object.entries(d1)) { + assert.equal(count, 1, `reload duplicate depth1 ${title}=${count}`); + } + + // Collapse all expanded, reload — icons must stay ▶ + for (const title of ["个人", "liaibo的个人空间"]) { + const row = r.sample.find((d) => d.title === title); + if (row && row.visuallyOpen) { + await clickToggleByTitle(page, title); + await page.waitForTimeout(300); + } + } + await page.waitForTimeout(900); + r = await waitDesyncClear(page); + results.steps.push({ name: "all-collapsed", r }); + assert.equal(r.desync.length, 0, `all-collapsed desync ${JSON.stringify(r.desync)}`); + for (const d of r.sample.filter((x) => x.toggleAria != null)) { + assert.equal(d.visuallyOpen, false, `should be closed ${d.title}`); + assert.equal(d.iconSaysOpen, false, `icon should be ▶ for ${d.title}`); + } + + await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.waitForSelector("#sidebar-tree-root .tree-row[data-shell-mode='page']", { + timeout: UI_TIMEOUT_MS, + }); + await page.waitForTimeout(2500); + await page.screenshot({ path: path.join(RESULT_DIR, "04-reload-collapsed.png") }); + r = await waitDesyncClear(page, 5000); + results.steps.push({ name: "after-reload-collapsed", r }); + assert.equal(r.dups.length, 0, `collapsed-reload dups ${JSON.stringify(r.dups)}`); + assert.equal(r.desync.length, 0, `collapsed-reload desync ${JSON.stringify(r.desync)}`); + assert.deepEqual(r.openRows, [], `collapsed reload openRows=${JSON.stringify(r.openRows)}`); + for (const d of r.sample.filter((x) => x.toggleAria != null)) { + assert.equal(d.iconSaysOpen, false, `collapsed reload icon open for ${d.title}`); + assert.equal(d.aria, "false", `collapsed reload aria for ${d.title}`); + assert.equal(d.dataExpanded, "false", `collapsed reload data-expanded for ${d.title}`); + } + + results.ok = true; + results.puts = puts; + results.gets = gets.slice(-6); + fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(results, null, 2)); + console.log( + JSON.stringify( + { + ok: true, + resultDir: RESULT_DIR, + openAfterExpand: results.steps.find((s) => s.name === "expanded")?.r?.openRows, + afterReload: results.steps.find((s) => s.name === "after-reload-open")?.r?.openRows, + afterCollapsedReload: results.steps.find((s) => s.name === "after-reload-collapsed")?.r + ?.openRows, + putCount: puts.length, + }, + null, + 2 + ) + ); + } catch (error) { + results.ok = false; + results.error = String((error && error.stack) || error); + fs.writeFileSync(path.join(RESULT_DIR, "result.json"), JSON.stringify(results, null, 2)); + fs.writeFileSync(path.join(RESULT_DIR, "server.log"), serverLog); + console.error(results.error); + process.exitCode = 1; + } finally { + await browser.close().catch(() => {}); + child.kill("SIGTERM"); + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch {} + }, 1000).unref(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/task-s1-effective-model-pi-start-smoke.js b/scripts/task-s1-effective-model-pi-start-smoke.js new file mode 100644 index 00000000..17cb843f --- /dev/null +++ b/scripts/task-s1-effective-model-pi-start-smoke.js @@ -0,0 +1,201 @@ +#!/usr/bin/env node +// S1: management defaultModel → effective → Pi status/start truth. +// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 (dev:hot default). +// Prefer MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock for a fast local loop. + +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE = process.env.MNOTE_S1_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; +const ACTOR_ID = process.env.MNOTE_S1_ACTOR_ID || "s1-effective-model-admin"; +const AUTH = process.env.MNOTE_S1_AUTH || "Bearer s1-effective-model"; +const ROOT = + process.env.MNOTE_S1_ROOT || + fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s1-effective-model-")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function fetchJson(url, options = {}) { + const headers = { + "Content-Type": "application/json", + Authorization: AUTH, + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "admin", + ...(options.headers || {}), + }; + const res = await fetch(url, { ...options, headers }); + const text = await res.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { status: res.status, body }; +} + +async function seedWorkspace(rootUri, rootPath) { + const seed = await fetchJson(`${BASE}/api/dev/seed`, { + method: "POST", + body: JSON.stringify({ + seeds: [ + { + kind: "setupWorkspace", + user_id: ACTOR_ID, + email: `${ACTOR_ID}@example.com`, + username: ACTOR_ID, + display_name: ACTOR_ID, + role: "admin", + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + workspace_id: `local-ws:${ACTOR_ID}:s1`, + workspace_name: "S1 effective model", + root_uri: rootUri, + root_path: rootPath, + source_kind: "local_folder", + permission: "write", + capabilities: ["ai"], + grant_source: "s1_effective_model_smoke", + grant_created_by: ACTOR_ID, + }, + ], + }), + }); + if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { + throw new Error( + "S1 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web(npm run dev:hot 默认开启)。", + ); + } + assert( + seed.status === 200 && seed.body.ok === true, + `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, + ); +} + +async function main() { + fs.mkdirSync(ROOT, { recursive: true }); + fs.writeFileSync(path.join(ROOT, "s1.md"), "# S1 effective model\n", "utf8"); + const rootUri = `file://${ROOT}`; + + console.log(`\n🧪 S1 effective model → Pi start (base=${BASE}, actor=${ACTOR_ID})\n`); + + await seedWorkspace(rootUri, ROOT); + + // Baseline admin settings (global owner for admin actor). + const adminBefore = await fetchJson(`${BASE}/api/ai-admin/settings`); + assert(adminBefore.status === 200 && adminBefore.body.ok === true, `admin get: ${adminBefore.status} ${JSON.stringify(adminBefore.body)}`); + const originalDefault = String(adminBefore.body.defaultModel || "omniroute/freefirst"); + let allowed = Array.isArray(adminBefore.body.allowedModels) + ? adminBefore.body.allowedModels.map(String) + : []; + // Ensure both candidates exist for the switch. + for (const model of ["omniroute/freefirst", "omniroute/gpt-5.4-mini", "omniroute/pi-fast", "omniroute/pi-reason"]) { + if (!allowed.includes(model)) allowed.push(model); + } + + const targetA = "omniroute/pi-fast"; + const targetB = "omniroute/pi-reason"; + console.log(` original defaultModel=${originalDefault}`); + + // ── Phase A: set default to pi-fast ───────────────────────────────── + const putA = await fetchJson(`${BASE}/api/ai-admin/settings`, { + method: "PUT", + body: JSON.stringify({ + defaultModel: targetA, + allowedModels: allowed, + }), + }); + assert(putA.status === 200 && putA.body.ok === true, `admin put A: ${putA.status} ${JSON.stringify(putA.body)}`); + + const effectiveA = await fetchJson(`${BASE}/api/ai-settings/effective`); + assert(effectiveA.status === 200, `effective A: ${effectiveA.status}`); + assert( + String(effectiveA.body.defaultModel) === targetA, + `effective A defaultModel=${effectiveA.body.defaultModel} expected ${targetA}`, + ); + console.log(` [A] effective.defaultModel=${effectiveA.body.defaultModel}`); + + const statusA = await fetchJson(`${BASE}/api/page-ai/pi/status`); + assert(statusA.status === 200 && statusA.body.ok === true, `status A: ${statusA.status} ${JSON.stringify(statusA.body)}`); + const statusRefA = String( + statusA.body.defaultModel || + `${statusA.body.defaultModelProvider}/${statusA.body.defaultModelId}`, + ); + assert( + statusRefA === targetA, + `status A default ${statusRefA} != ${targetA}`, + ); + console.log(` [A] status.defaultModel=${statusRefA}`); + + const startA = await fetchJson(`${BASE}/api/page-ai/pi/start`, { + method: "POST", + body: JSON.stringify({ + sessionId: `pi_lab_s1_a_${Date.now()}`, + rootUri, + // omit modelProvider/modelId → must use default + }), + }); + assert(startA.status === 200 && startA.body.ok === true, `start A: ${startA.status} ${JSON.stringify(startA.body)}`); + const sessionA = startA.body.session || {}; + const startedA = `${sessionA.modelProvider}/${sessionA.modelId}`; + assert(startedA === targetA, `start A model ${startedA} != ${targetA}`); + console.log(` [A] start session model=${startedA}`); + + // ── Phase B: change default to pi-reason; next start must pick it up ─ + const putB = await fetchJson(`${BASE}/api/ai-admin/settings`, { + method: "PUT", + body: JSON.stringify({ + defaultModel: targetB, + allowedModels: allowed, + }), + }); + assert(putB.status === 200 && putB.body.ok === true, `admin put B: ${putB.status} ${JSON.stringify(putB.body)}`); + + const effectiveB = await fetchJson(`${BASE}/api/ai-settings/effective`); + assert(String(effectiveB.body.defaultModel) === targetB, `effective B=${effectiveB.body.defaultModel}`); + console.log(` [B] effective.defaultModel=${effectiveB.body.defaultModel}`); + + const statusB = await fetchJson(`${BASE}/api/page-ai/pi/status`); + const statusRefB = String( + statusB.body.defaultModel || + `${statusB.body.defaultModelProvider}/${statusB.body.defaultModelId}`, + ); + assert(statusRefB === targetB, `status B default ${statusRefB} != ${targetB}`); + console.log(` [B] status.defaultModel=${statusRefB}`); + + const startB = await fetchJson(`${BASE}/api/page-ai/pi/start`, { + method: "POST", + body: JSON.stringify({ + sessionId: `pi_lab_s1_b_${Date.now()}`, + rootUri, + }), + }); + assert(startB.status === 200 && startB.body.ok === true, `start B: ${startB.status} ${JSON.stringify(startB.body)}`); + const sessionB = startB.body.session || {}; + const startedB = `${sessionB.modelProvider}/${sessionB.modelId}`; + assert( + startedB === targetB, + `start B model ${startedB} != ${targetB} (policy change must apply on next start)`, + ); + console.log(` [B] start session model=${startedB}`); + + // Best-effort restore + if (originalDefault) { + await fetchJson(`${BASE}/api/ai-admin/settings`, { + method: "PUT", + body: JSON.stringify({ defaultModel: originalDefault, allowedModels: allowed }), + }); + console.log(` restored defaultModel=${originalDefault}`); + } + + console.log("\n✅ S1 effective model → Pi start smoke passed\n"); +} + +main().catch((err) => { + console.error("\n❌ S1 smoke failed:", err.message || err); + process.exit(1); +}); diff --git a/scripts/task-s2-skills-mcp-delete-no-ghost-smoke.js b/scripts/task-s2-skills-mcp-delete-no-ghost-smoke.js new file mode 100644 index 00000000..374325d2 --- /dev/null +++ b/scripts/task-s2-skills-mcp-delete-no-ghost-smoke.js @@ -0,0 +1,206 @@ +#!/usr/bin/env node +// S2: skills/MCP delete → removedDefault* tombstones; effective has no ghosts. +// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1. +// Prefer MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock for a fast local loop. + +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE = process.env.MNOTE_S2_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; +const ACTOR_ID = process.env.MNOTE_S2_ACTOR_ID || "s2-skills-mcp-admin"; +const AUTH = process.env.MNOTE_S2_AUTH || "Bearer s2-skills-mcp"; +const ROOT = + process.env.MNOTE_S2_ROOT || + fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s2-skills-mcp-")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function fetchJson(url, options = {}) { + const headers = { + "Content-Type": "application/json", + Authorization: AUTH, + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "admin", + ...(options.headers || {}), + }; + const res = await fetch(url, { ...options, headers }); + const text = await res.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { status: res.status, body }; +} + +async function seedWorkspace(rootUri, rootPath) { + const seed = await fetchJson(`${BASE}/api/dev/seed`, { + method: "POST", + body: JSON.stringify({ + seeds: [ + { + kind: "setupWorkspace", + user_id: ACTOR_ID, + email: `${ACTOR_ID}@example.com`, + username: ACTOR_ID, + display_name: ACTOR_ID, + role: "admin", + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + workspace_id: `local-ws:${ACTOR_ID}:s2`, + workspace_name: "S2 skills MCP delete", + root_uri: rootUri, + root_path: rootPath, + source_kind: "local_folder", + permission: "write", + capabilities: ["ai"], + grant_source: "s2_skills_mcp_smoke", + grant_created_by: ACTOR_ID, + }, + ], + }), + }); + if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { + throw new Error( + "S2 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", + ); + } + assert( + seed.status === 200 && seed.body.ok === true, + `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, + ); +} + +function skillIds(listOrMap) { + if (Array.isArray(listOrMap)) { + return listOrMap.map((s) => String(s.name || s.id || "")).filter(Boolean); + } + if (listOrMap && typeof listOrMap === "object") { + return Object.keys(listOrMap); + } + return []; +} + +function mcpIds(listOrMap) { + if (Array.isArray(listOrMap)) { + return listOrMap.map((s) => String(s.name || s.id || "")).filter(Boolean); + } + if (listOrMap && typeof listOrMap === "object") { + return Object.keys(listOrMap); + } + return []; +} + +async function main() { + fs.mkdirSync(ROOT, { recursive: true }); + fs.writeFileSync(path.join(ROOT, "s2.md"), "# S2 skills MCP delete\n", "utf8"); + const rootUri = `file://${ROOT}`; + + console.log(`\n🧪 S2 skills/MCP delete no ghosts (base=${BASE}, actor=${ACTOR_ID})\n`); + + await seedWorkspace(rootUri, ROOT); + + const adminBefore = await fetchJson(`${BASE}/api/ai-admin/settings`); + assert( + adminBefore.status === 200 && adminBefore.body.ok === true, + `admin get: ${adminBefore.status} ${JSON.stringify(adminBefore.body)}`, + ); + + const beforeSkills = skillIds(adminBefore.body.skills); + const beforeMcps = mcpIds(adminBefore.body.mcpServers); + assert(beforeSkills.includes("vpn") || beforeSkills.length > 0, "expected default skills in admin"); + console.log(` baseline skills=${beforeSkills.length} mcp=${beforeMcps.length}`); + + // Keep only a custom skill + custom mcp; omit all defaults → tombstones. + const keepSkill = { + "s2-keep-skill": { + name: "S2 Keep Skill", + enabled: true, + description: "s2 keep", + source: path.join(ROOT, "SKILL.md"), + riskLevel: "low", + requiredScopes: [], + }, + }; + const keepMcp = { + "s2-keep-mcp": { + name: "S2 Keep MCP", + enabled: true, + url: "", + transport: "stdio", + command: "s2-keep-mcp", + networkPolicy: "deny-all", + secretRefs: [], + facadeOnly: true, + sandbox: true, + description: "s2 keep mcp", + riskLevel: "medium", + requiredScopes: [], + }, + }; + fs.writeFileSync(path.join(ROOT, "SKILL.md"), "# S2 Keep\n", "utf8"); + + const put = await fetchJson(`${BASE}/api/ai-admin/settings`, { + method: "PUT", + body: JSON.stringify({ + skills: keepSkill, + mcpServers: keepMcp, + }), + }); + assert(put.status === 200 && put.body.ok === true, `admin put: ${put.status} ${JSON.stringify(put.body)}`); + + const adminAfter = await fetchJson(`${BASE}/api/ai-admin/settings`); + assert(adminAfter.status === 200 && adminAfter.body.ok === true, `admin after: ${adminAfter.status}`); + const afterSkills = skillIds(adminAfter.body.skills); + const afterMcps = mcpIds(adminAfter.body.mcpServers); + assert( + afterSkills.includes("s2-keep-skill") || afterSkills.includes("S2 Keep Skill"), + `admin skills missing keep: ${JSON.stringify(afterSkills)}`, + ); + assert(!afterSkills.includes("vpn"), `ghost skill vpn still in admin: ${JSON.stringify(afterSkills)}`); + assert( + afterMcps.includes("s2-keep-mcp") || afterMcps.includes("S2 Keep MCP"), + `admin mcp missing keep: ${JSON.stringify(afterMcps)}`, + ); + assert(!afterMcps.includes("context7"), `ghost mcp context7 still in admin: ${JSON.stringify(afterMcps)}`); + console.log(` [admin] skills=${afterSkills.join(",")} mcp=${afterMcps.join(",")}`); + + const effective = await fetchJson(`${BASE}/api/ai-settings/effective`); + assert(effective.status === 200, `effective: ${effective.status}`); + const effSkills = skillIds(effective.body.skills); + const effMcps = mcpIds(effective.body.mcpServers); + assert(!effSkills.includes("vpn"), `effective still has vpn ghost: ${JSON.stringify(effSkills)}`); + assert( + !effSkills.some((id) => id === "vpn" || id.toLowerCase() === "vpn"), + `effective skills contain vpn: ${JSON.stringify(effSkills)}`, + ); + assert(!effMcps.includes("context7"), `effective still has context7 ghost: ${JSON.stringify(effMcps)}`); + assert( + effSkills.some((id) => id.includes("S2 Keep") || id === "s2-keep-skill"), + `effective missing keep skill: ${JSON.stringify(effSkills)}`, + ); + console.log(` [effective] skills=${effSkills.join(",")} mcp=${effMcps.join(",")}`); + + // Second put with same payload must remain ghost-free (tombstones sticky). + const put2 = await fetchJson(`${BASE}/api/ai-admin/settings`, { + method: "PUT", + body: JSON.stringify({ skills: keepSkill, mcpServers: keepMcp }), + }); + assert(put2.status === 200 && put2.body.ok === true, `admin put2: ${put2.status}`); + const effective2 = await fetchJson(`${BASE}/api/ai-settings/effective`); + const effSkills2 = skillIds(effective2.body.skills); + assert(!effSkills2.includes("vpn"), `ghost rehydrated after second put: ${JSON.stringify(effSkills2)}`); + console.log(" [sticky] second put still tombstoned defaults"); + + console.log("\n✅ S2 skills/MCP delete no ghosts smoke passed\n"); +} + +main().catch((err) => { + console.error("\n❌ S2 smoke failed:", err.message || err); + process.exit(1); +}); diff --git a/scripts/task-s3-directory-grants-allowed-roots-smoke.js b/scripts/task-s3-directory-grants-allowed-roots-smoke.js new file mode 100644 index 00000000..7d468516 --- /dev/null +++ b/scripts/task-s3-directory-grants-allowed-roots-smoke.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// S3: admin access-scopes SoT = directory_grants (full grant table), same as access-policy. +// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1. + +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE = process.env.MNOTE_S3_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; +const ACTOR_ID = process.env.MNOTE_S3_ACTOR_ID || "s3-grants-admin"; +const OTHER_ID = process.env.MNOTE_S3_OTHER_ID || "s3-grants-other-user"; +const AUTH = process.env.MNOTE_S3_AUTH || "Bearer s3-grants"; +const ROOT = + process.env.MNOTE_S3_ROOT || + fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s3-grants-")); +const OTHER_ROOT = + process.env.MNOTE_S3_OTHER_ROOT || + fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s3-grants-other-")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function fetchJson(url, options = {}) { + const headers = { + "Content-Type": "application/json", + Authorization: AUTH, + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "admin", + ...(options.headers || {}), + }; + const res = await fetch(url, { ...options, headers }); + const text = await res.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { status: res.status, body }; +} + +async function seed() { + const seed = await fetchJson(`${BASE}/api/dev/seed`, { + method: "POST", + body: JSON.stringify({ + seeds: [ + { + kind: "setupWorkspace", + user_id: ACTOR_ID, + email: `${ACTOR_ID}@example.com`, + username: ACTOR_ID, + display_name: ACTOR_ID, + role: "admin", + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + workspace_id: `local-ws:${ACTOR_ID}:s3`, + workspace_name: "S3 grants admin", + root_uri: `file://${ROOT}`, + root_path: ROOT, + source_kind: "local_folder", + permission: "write", + capabilities: ["ai"], + grant_source: "s3_grants_smoke", + grant_created_by: ACTOR_ID, + }, + { + kind: "setupWorkspace", + user_id: OTHER_ID, + email: `${OTHER_ID}@example.com`, + username: OTHER_ID, + display_name: OTHER_ID, + role: "user", + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + workspace_id: `local-ws:${OTHER_ID}:s3`, + workspace_name: "S3 grants other", + root_uri: `file://${OTHER_ROOT}`, + root_path: OTHER_ROOT, + source_kind: "local_folder", + permission: "write", + capabilities: ["ai"], + grant_source: "s3_grants_smoke_other", + grant_created_by: ACTOR_ID, + }, + ], + }), + }); + if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { + throw new Error( + "S3 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", + ); + } + assert( + seed.status === 200 && seed.body.ok === true, + `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, + ); +} + +function rootUris(allowedRoots) { + return (Array.isArray(allowedRoots) ? allowedRoots : []) + .map((r) => String(r.rootUri || r.root_uri || "")) + .filter(Boolean); +} + +function userIds(allowedRoots) { + return (Array.isArray(allowedRoots) ? allowedRoots : []) + .map((r) => String(r.userId || r.user_id || "")) + .filter(Boolean); +} + +async function main() { + fs.mkdirSync(ROOT, { recursive: true }); + fs.mkdirSync(OTHER_ROOT, { recursive: true }); + fs.writeFileSync(path.join(ROOT, "s3.md"), "# S3 grants\n", "utf8"); + fs.writeFileSync(path.join(OTHER_ROOT, "other.md"), "# other\n", "utf8"); + + console.log(`\n🧪 S3 directory grants ≡ allowed roots (base=${BASE})\n`); + + await seed(); + + const adminScopes = await fetchJson(`${BASE}/api/ai-admin/access-scopes`); + assert( + adminScopes.status === 200, + `admin access-scopes: ${adminScopes.status} ${JSON.stringify(adminScopes.body)}`, + ); + assert( + String(adminScopes.body.sourceOfTruth || adminScopes.body.source_of_truth) === "directory_grants", + `sourceOfTruth expected directory_grants, got ${adminScopes.body.sourceOfTruth}`, + ); + const adminUris = rootUris(adminScopes.body.allowedRoots || adminScopes.body.allowed_roots); + const adminUsers = userIds(adminScopes.body.allowedRoots || adminScopes.body.allowed_roots); + console.log(` admin scopes: ${adminUris.length} roots, users=${[...new Set(adminUsers)].join(",")}`); + + // Admin SoT must include both admin actor grant and other user's grant (full table). + assert( + adminUris.some((u) => u.includes(ROOT) || u === `file://${ROOT}`), + `admin scopes missing admin root: ${JSON.stringify(adminUris)}`, + ); + assert( + adminUris.some((u) => u.includes(OTHER_ROOT) || u === `file://${OTHER_ROOT}`), + `admin scopes missing OTHER user grant (must be full grant table, not actor-scoped): ${JSON.stringify(adminUris)}`, + ); + assert( + adminUsers.includes(OTHER_ID) || adminUsers.some((u) => u.includes("s3-grants-other")), + `admin scopes should list other user_id: ${JSON.stringify(adminUsers)}`, + ); + + // User-facing scopes for admin actor should be actor-scoped (own grants only). + const userScopes = await fetchJson(`${BASE}/api/ai-settings/access-scopes`); + assert(userScopes.status === 200, `user access-scopes: ${userScopes.status}`); + assert( + String(userScopes.body.sourceOfTruth || userScopes.body.source_of_truth) === "directory_grants", + `user sourceOfTruth expected directory_grants`, + ); + const userUris = rootUris(userScopes.body.allowedRoots || userScopes.body.allowed_roots); + assert( + userUris.some((u) => u.includes(ROOT) || u === `file://${ROOT}`), + `user scopes missing own root`, + ); + // Own actor scopes should not include unrelated other-root as "mine" identity — + // but if system seeds extra grants for admin, still require admin endpoint ≥ user endpoint. + assert( + adminUris.length >= userUris.length, + `admin full table (${adminUris.length}) should cover at least actor scopes (${userUris.length})`, + ); + console.log(` user (actor) scopes: ${userUris.length}; admin full: ${adminUris.length}`); + + // Effective also declares directory_grants SoT. + const effective = await fetchJson(`${BASE}/api/ai-settings/effective`); + assert(effective.status === 200, `effective: ${effective.status}`); + assert( + String(effective.body.sourceOfTruth || effective.body.source_of_truth) === "directory_grants", + `effective sourceOfTruth: ${effective.body.sourceOfTruth}`, + ); + console.log(" effective.sourceOfTruth=directory_grants"); + + console.log("\n✅ S3 directory grants ≡ allowed roots smoke passed\n"); +} + +main().catch((err) => { + console.error("\n❌ S3 smoke failed:", err.message || err); + process.exit(1); +}); diff --git a/scripts/task-s4-pi-abort-idempotent-smoke.js b/scripts/task-s4-pi-abort-idempotent-smoke.js new file mode 100644 index 00000000..6a9c60a3 --- /dev/null +++ b/scripts/task-s4-pi-abort-idempotent-smoke.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// S4: Pi abort once — API abort works; client reducer is idempotent (static + mock API). +// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 + MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock. + +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE = process.env.MNOTE_S4_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; +const ACTOR_ID = process.env.MNOTE_S4_ACTOR_ID || "s4-abort-admin"; +const AUTH = process.env.MNOTE_S4_AUTH || "Bearer s4-abort"; +const ROOT = + process.env.MNOTE_S4_ROOT || + fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s4-abort-")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function fetchJson(url, options = {}) { + const headers = { + "Content-Type": "application/json", + Authorization: AUTH, + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "admin", + ...(options.headers || {}), + }; + const res = await fetch(url, { ...options, headers }); + const text = await res.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { status: res.status, body }; +} + +async function seedWorkspace(rootUri, rootPath) { + const seed = await fetchJson(`${BASE}/api/dev/seed`, { + method: "POST", + body: JSON.stringify({ + seeds: [ + { + kind: "setupWorkspace", + user_id: ACTOR_ID, + email: `${ACTOR_ID}@example.com`, + username: ACTOR_ID, + display_name: ACTOR_ID, + role: "admin", + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + workspace_id: `local-ws:${ACTOR_ID}:s4`, + workspace_name: "S4 abort", + root_uri: rootUri, + root_path: rootPath, + source_kind: "local_folder", + permission: "write", + capabilities: ["ai"], + grant_source: "s4_abort_smoke", + grant_created_by: ACTOR_ID, + }, + ], + }), + }); + if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { + throw new Error( + "S4 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", + ); + } + assert( + seed.status === 200 && seed.body.ok === true, + `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, + ); +} + +function staticClientChecks() { + const runtimePath = path.join( + __dirname, + "..", + "rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js", + ); + const src = fs.readFileSync(runtimePath, "utf8"); + assert( + src.includes("assistant_abort") && src.includes("STATE_ABORTED"), + "client must handle assistant_abort → STATE_ABORTED", + ); + assert( + src.includes("abort is idempotent") || + (src.includes("runtimeStatus === STATE_ABORTED") && src.includes("assistant_abort")), + "client reducer must short-circuit second assistant_abort", + ); + // Optimistic single apply before fire-and-forget ABORT. + assert( + /function abortPrompt[\s\S]*?applyPiRunEvent\(\{\s*type:\s*'assistant_abort'/.test(src) || + /function abortPrompt[\s\S]*?applyPiRunEvent\(\{ type: 'assistant_abort'/.test(src), + "abortPrompt must optimistically apply assistant_abort once", + ); + assert( + /function abortPrompt[\s\S]*?fetch\(API\.ABORT/.test(src) || + /function abortPrompt[\s\S]*?API\.ABORT/.test(src), + "abortPrompt must still call ABORT API", + ); + console.log(" [static] client abort once + idempotent reducer OK"); +} + +async function main() { + staticClientChecks(); + + fs.mkdirSync(ROOT, { recursive: true }); + fs.writeFileSync(path.join(ROOT, "s4.md"), "# S4 abort\n", "utf8"); + const rootUri = `file://${ROOT}`; + + console.log(`\n🧪 S4 Pi abort idempotent (base=${BASE}, actor=${ACTOR_ID})\n`); + + await seedWorkspace(rootUri, ROOT); + + const status = await fetchJson(`${BASE}/api/page-ai/pi/status`); + assert(status.status === 200 && status.body.ok === true, `status: ${status.status}`); + if (status.body.runtimeMode && status.body.runtimeMode !== "mock") { + console.log(` ⚠ runtimeMode=${status.body.runtimeMode} (prefer mock for this smoke)`); + } + + const sessionId = `pi_lab_s4_${Date.now()}`; + const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, { + method: "POST", + body: JSON.stringify({ + sessionId, + rootUri, + workspaceId: `local-ws:${ACTOR_ID}:s4`, + pagePath: "s4.md", + pageTitle: "S4 abort", + }), + }); + assert(start.status === 200 && start.body.ok === true, `start: ${start.status} ${JSON.stringify(start.body)}`); + const sid = (start.body.session && start.body.session.sessionId) || sessionId; + console.log(` started ${sid}`); + + // Send may stream; abort immediately after. + const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, { + method: "POST", + body: JSON.stringify({ + sessionId: sid, + message: "S4 long answer please keep streaming", + rootUri, + }), + }); + // Mock may accept or complete quickly — either is fine before abort. + assert( + send.status === 200 || send.status === 409 || send.status === 400, + `send unexpected: ${send.status} ${JSON.stringify(send.body)}`, + ); + console.log(` send status=${send.status}`); + + const abort1 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { + method: "POST", + body: JSON.stringify({ sessionId: sid }), + }); + assert( + abort1.status === 200 && abort1.body.ok === true && abort1.body.aborted === true, + `abort1: ${abort1.status} ${JSON.stringify(abort1.body)}`, + ); + assert(abort1.body.stopReason === "aborted" || abort1.body.aborted === true, "abort1 stopReason"); + console.log(" abort1 ok"); + + // Second abort must still succeed (idempotent server) without double-error. + const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { + method: "POST", + body: JSON.stringify({ sessionId: sid }), + }); + assert( + abort2.status === 200 && abort2.body.ok === true && abort2.body.aborted === true, + `abort2 must be idempotent: ${abort2.status} ${JSON.stringify(abort2.body)}`, + ); + console.log(" abort2 idempotent ok"); + + console.log("\n✅ S4 Pi abort idempotent smoke passed\n"); +} + +main().catch((err) => { + console.error("\n❌ S4 smoke failed:", err.message || err); + process.exit(1); +}); diff --git a/scripts/task-s5-rag-health-summary-filetree-smoke.js b/scripts/task-s5-rag-health-summary-filetree-smoke.js new file mode 100644 index 00000000..1a4dcdd9 --- /dev/null +++ b/scripts/task-s5-rag-health-summary-filetree-smoke.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// S5: RAG status health + summary shape + FileTree tooltip static contract. +// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 (LightRAG may be down — status still ok:true). + +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE = process.env.MNOTE_S5_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; +const ACTOR_ID = process.env.MNOTE_S5_ACTOR_ID || "s5-rag-admin"; +const AUTH = process.env.MNOTE_S5_AUTH || "Bearer s5-rag"; +const ROOT = + process.env.MNOTE_S5_ROOT || + fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s5-rag-")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function fetchJson(url, options = {}) { + const headers = { + "Content-Type": "application/json", + Authorization: AUTH, + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "admin", + ...(options.headers || {}), + }; + const res = await fetch(url, { ...options, headers }); + const text = await res.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { status: res.status, body }; +} + +async function seedWorkspace(rootUri, rootPath) { + const seed = await fetchJson(`${BASE}/api/dev/seed`, { + method: "POST", + body: JSON.stringify({ + seeds: [ + { + kind: "setupWorkspace", + user_id: ACTOR_ID, + email: `${ACTOR_ID}@example.com`, + username: ACTOR_ID, + display_name: ACTOR_ID, + role: "admin", + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + workspace_id: `local-ws:${ACTOR_ID}:s5`, + workspace_name: "S5 RAG", + root_uri: rootUri, + root_path: rootPath, + source_kind: "local_folder", + permission: "write", + capabilities: ["ai"], + grant_source: "s5_rag_smoke", + grant_created_by: ACTOR_ID, + }, + ], + }), + }); + if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { + throw new Error( + "S5 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", + ); + } + assert( + seed.status === 200 && seed.body.ok === true, + `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, + ); +} + +function staticChecks() { + const liveApply = path.join( + __dirname, + "..", + "rust/crates/mnote-web/browser/sidebar-tree-live-apply-runtime.js", + ); + const aiAdmin = path.join( + __dirname, + "..", + "rust/crates/mnote-web/src/ssr/pages/ai_admin.rs", + ); + const liveSrc = fs.readFileSync(liveApply, "utf8"); + const adminSrc = fs.readFileSync(aiAdmin, "utf8"); + + assert(liveSrc.includes("indexStatusLabel"), "FileTree must compute indexStatusLabel"); + assert( + liveSrc.includes("已索引") && liveSrc.includes("索引中") && liveSrc.includes("索引失败"), + "FileTree tooltip labels for indexed/indexing/failed", + ); + assert( + liveSrc.includes("linkTitle") && + (liveSrc.includes("title=\"") || liveSrc.includes("title='") || liveSrc.includes("title=\"") || liveSrc.includes("title=") || liveSrc.includes("title=")), + "FileTree rows should set title/linkTitle for tooltip", + ); + // Prefer title attribute wiring for tooltip. + assert( + /linkTitle[\s\S]{0,400}title=/.test(liveSrc) || + /title=[\s\S]{0,200}linkTitle/.test(liveSrc) || + liveSrc.includes("escapeHtml(linkTitle)"), + "linkTitle must be wired into element title attribute", + ); + + assert( + adminSrc.includes("/api/knowledge-rag/status"), + "AI admin knowledge panel must fetch /api/knowledge-rag/status", + ); + assert( + adminSrc.includes("data-ai-admin-knowledge-summary") || + adminSrc.includes("knowledge-summary"), + "AI admin must render knowledge summary region", + ); + console.log(" [static] FileTree tooltip + admin knowledge summary OK"); +} + +async function main() { + staticChecks(); + + fs.mkdirSync(ROOT, { recursive: true }); + fs.writeFileSync(path.join(ROOT, "s5.md"), "# S5 RAG\n", "utf8"); + const rootUri = `file://${ROOT}`; + const workspaceId = `local-ws:${ACTOR_ID}:s5`; + + console.log(`\n🧪 S5 RAG health + summary (base=${BASE})\n`); + + await seedWorkspace(rootUri, ROOT); + + const bare = await fetchJson(`${BASE}/api/knowledge-rag/status`); + assert(bare.status === 200 && bare.body.ok === true, `status bare: ${bare.status} ${JSON.stringify(bare.body).slice(0, 300)}`); + assert( + String(bare.body.schema) === "mnote.knowledge_rag.provider_status.v1", + `schema: ${bare.body.schema}`, + ); + assert(bare.body.health !== undefined, "status must include health"); + assert(bare.body.pipeline !== undefined || bare.body.documents !== undefined, "status must include pipeline or documents"); + console.log( + ` bare: provider=${bare.body.provider} health.ok=${bare.body.health && bare.body.health.ok}`, + ); + + const scoped = await fetchJson( + `${BASE}/api/knowledge-rag/status?workspaceId=${encodeURIComponent(workspaceId)}&rootUri=${encodeURIComponent(rootUri)}`, + ); + assert( + scoped.status === 200 && scoped.body.ok === true, + `status scoped: ${scoped.status} ${JSON.stringify(scoped.body).slice(0, 300)}`, + ); + // Scoped should attach registry when root is readable. + assert( + scoped.body.registry !== undefined || scoped.body.registryDiagnostics !== undefined, + "scoped status should include registry or diagnostics", + ); + console.log( + ` scoped: registry=${scoped.body.registry ? "yes" : "no"} knowledgeBases=${scoped.body.knowledgeBases ? "yes" : "no"}`, + ); + + // Admin UI page should mention knowledge status (SSR shell). + const adminPage = await fetch(`${BASE}/ai-admin`, { + headers: { + Authorization: AUTH, + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "admin", + }, + redirect: "manual", + }); + // May 200 or redirect depending on auth shell — only soft-check when 200. + if (adminPage.status === 200) { + const html = await adminPage.text(); + assert( + html.includes("knowledge-rag") || + html.includes("data-ai-admin-knowledge") || + html.includes("知识"), + "ai-admin HTML should include knowledge panel markers", + ); + console.log(" ai-admin HTML knowledge panel markers present"); + } else { + console.log(` ai-admin HTTP ${adminPage.status} (skip HTML; API shape verified)`); + } + + console.log("\n✅ S5 RAG health + summary + FileTree smoke passed\n"); +} + +main().catch((err) => { + console.error("\n❌ S5 smoke failed:", err.message || err); + process.exit(1); +}); diff --git a/scripts/task-s6-pi-resume-history-hydrate-smoke.js b/scripts/task-s6-pi-resume-history-hydrate-smoke.js new file mode 100644 index 00000000..58e5971f --- /dev/null +++ b/scripts/task-s6-pi-resume-history-hydrate-smoke.js @@ -0,0 +1,212 @@ +#!/usr/bin/env node +// S6: Pi session resume/history from control-plane (seedAiRuntime + hydrate). +// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 + MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock. + +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE = process.env.MNOTE_S6_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; +const ACTOR_ID = process.env.MNOTE_S6_ACTOR_ID || "s6-hydrate-admin"; +const AUTH = process.env.MNOTE_S6_AUTH || "Bearer s6-hydrate"; +const ROOT = + process.env.MNOTE_S6_ROOT || + fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s6-hydrate-")); +const STAMP = Date.now(); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function fetchJson(url, options = {}) { + const headers = { + "Content-Type": "application/json", + Authorization: AUTH, + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "admin", + ...(options.headers || {}), + }; + const res = await fetch(url, { ...options, headers }); + const text = await res.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { status: res.status, body }; +} + +async function main() { + fs.mkdirSync(ROOT, { recursive: true }); + const pagePath = `s6-hydrate-${STAMP}.md`; + fs.writeFileSync(path.join(ROOT, pagePath), "# S6 hydrate\n", "utf8"); + const rootUri = `file://${ROOT}`; + const workspaceId = `local-ws:${ACTOR_ID}:s6`; + const sessionId = `pi_lab_s6_${STAMP}`; + const runId = `pi_run_${sessionId}`; + const piSessionDir = path.join(ROOT, "pi-session"); + const piSessionFile = path.join(piSessionDir, `${STAMP}_session.jsonl`); + fs.mkdirSync(piSessionDir, { recursive: true }); + const historyText = `S6_HYDRATE_REPLY_${STAMP}`; + fs.writeFileSync( + piSessionFile, + [ + JSON.stringify({ + id: "u1", + type: "message", + message: { role: "user", content: "hydrate me" }, + seq: 1, + }), + JSON.stringify({ + id: "a1", + parentId: "u1", + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: historyText }], + }, + seq: 2, + }), + ].join("\n") + "\n", + "utf8", + ); + + console.log(`\n🧪 S6 Pi resume/history hydrate (base=${BASE}, session=${sessionId})\n`); + + const seed = await fetchJson(`${BASE}/api/dev/seed`, { + method: "POST", + body: JSON.stringify({ + seeds: [ + { + kind: "setupWorkspace", + user_id: ACTOR_ID, + email: `${ACTOR_ID}@example.com`, + username: ACTOR_ID, + display_name: ACTOR_ID, + role: "admin", + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + workspace_id: workspaceId, + workspace_name: "S6 hydrate", + root_uri: rootUri, + root_path: ROOT, + source_kind: "local_folder", + permission: "write", + capabilities: ["ai"], + grant_source: "s6_hydrate_smoke", + grant_created_by: ACTOR_ID, + }, + { + kind: "seedAiRuntime", + user_id: ACTOR_ID, + workspace_id: workspaceId, + document_id: pagePath, + session_id: sessionId, + run_id: runId, + title: "S6 hydrate session", + profile: "pi_lab", + acp_runtime: "pi", + status: "runtime_running", + runtime_json: { + runtimeMode: "mock", + rootUri, + workspaceId, + pagePath, + pageTitle: "S6 hydrate", + modelProvider: "omniroute", + modelId: "pi-fast", + thinkingLevel: "medium", + piSessionDir, + piSessionFile, + messageCount: 2, + }, + payload_json: { message: "s6 hydrate" }, + events: [ + { + eventType: "user_prompt", + payloadJson: { message: "hydrate me" }, + }, + { + eventType: "session_seeded", + payloadJson: { source: "s6_smoke" }, + }, + ], + }, + ], + }), + }); + if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { + throw new Error( + "S6 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", + ); + } + assert( + seed.status === 200 && seed.body.ok === true, + `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, + ); + console.log(" seeded workspace + ai runtime"); + + // list_sessions should surface the seeded Pi Lab run from control-plane. + const listed = await fetchJson( + `${BASE}/api/page-ai/pi/sessions?workspaceId=${encodeURIComponent(workspaceId)}&limit=50`, + ); + assert(listed.status === 200 && listed.body.ok === true, `list: ${listed.status} ${JSON.stringify(listed.body)}`); + assert( + String(listed.body.schema) === "mnote.page_ai_pi.list_sessions.v1", + `list schema: ${listed.body.schema}`, + ); + const sessions = Array.isArray(listed.body.sessions) ? listed.body.sessions : []; + const found = sessions.find((s) => String(s.sessionId) === sessionId); + assert(found, `list_sessions missing ${sessionId}: ${JSON.stringify(sessions).slice(0, 500)}`); + console.log(` list_sessions includes ${sessionId} status=${found.status}`); + + // get_session_history should return the control-plane run (even without hot cache). + const history = await fetchJson(`${BASE}/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}`); + assert( + history.status === 200 && history.body.ok !== false, + `history: ${history.status} ${JSON.stringify(history.body).slice(0, 400)}`, + ); + // Accept either structured history messages or run metadata with runtime json. + const bodyText = JSON.stringify(history.body); + assert( + bodyText.includes(sessionId) || + bodyText.includes(runId) || + bodyText.includes("hydrate") || + bodyText.includes(historyText) || + bodyText.includes("pi_lab"), + `history payload should identify session: ${bodyText.slice(0, 500)}`, + ); + console.log(" get_session_history ok"); + + // Cold path: abort uses get_session_for_context → hydrate_session_from_control_plane. + // Session is NOT in process hot cache after seed-only; hydrate must restore it. + const abort = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { + method: "POST", + body: JSON.stringify({ sessionId }), + }); + assert( + abort.status === 200 && abort.body.ok === true && abort.body.aborted === true, + `hydrate via abort failed (session not restored from control-plane): ${abort.status} ${JSON.stringify(abort.body)}`, + ); + console.log(" hydrate via abort: session restored from control-plane"); + + // Second operation after hydrate still works (hot cache now warm). + const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { + method: "POST", + body: JSON.stringify({ sessionId }), + }); + assert( + abort2.status === 200 && abort2.body.ok === true, + `warm abort: ${abort2.status} ${JSON.stringify(abort2.body)}`, + ); + console.log(" warm-cache abort ok"); + + console.log("\n✅ S6 Pi resume/history hydrate smoke passed\n"); +} + +main().catch((err) => { + console.error("\n❌ S6 smoke failed:", err.message || err); + process.exit(1); +}); diff --git a/scripts/task-s7-agent-target-dirty-guard-smoke.js b/scripts/task-s7-agent-target-dirty-guard-smoke.js new file mode 100644 index 00000000..4664b891 --- /dev/null +++ b/scripts/task-s7-agent-target-dirty-guard-smoke.js @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// S7: agent target package on send + dirty write conflict gate (client static + API accept). +// Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 + MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock. + +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const BASE = process.env.MNOTE_S7_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; +const ACTOR_ID = process.env.MNOTE_S7_ACTOR_ID || "s7-target-admin"; +const AUTH = process.env.MNOTE_S7_AUTH || "Bearer s7-target"; +const ROOT = + process.env.MNOTE_S7_ROOT || + fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s7-target-")); + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function fetchJson(url, options = {}) { + const headers = { + "Content-Type": "application/json", + Authorization: AUTH, + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "admin", + ...(options.headers || {}), + }; + const res = await fetch(url, { ...options, headers }); + const text = await res.text(); + let body = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + body = { raw: text }; + } + return { status: res.status, body }; +} + +function staticChecks() { + const piLab = path.join( + __dirname, + "..", + "rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js", + ); + const src = fs.readFileSync(piLab, "utf8"); + assert(src.includes("mnote.agent_target_package.v1"), "must build agent target package v1"); + assert( + src.includes("buildPiLabAgentTargetPackage") || src.includes("targetPackage"), + "must attach targetPackage on send", + ); + assert( + src.includes("assertPiLabTargetWritable") || src.includes("page_ai_target_buffer_not_writable"), + "must gate dirty buffer before send", + ); + assert( + src.includes("writeRequiresCleanBuffer") || src.includes("fail_on_dirty_or_stale"), + "policy must require clean buffer / fail on dirty", + ); + assert( + src.includes("targetPackage: targetPackage") || src.includes("targetPackage:targetPackage"), + "send body must include targetPackage", + ); + + const runtimeRs = path.join( + __dirname, + "..", + "rust/crates/mnote-web/src/routes/page_ai_pi/runtime.rs", + ); + const rs = fs.readFileSync(runtimeRs, "utf8"); + assert( + rs.includes("target_package") && rs.includes("PiLabSendRequest"), + "server PiLabSendRequest must accept target_package", + ); + console.log(" [static] client target package + dirty gate + server accept field OK"); +} + +async function seedWorkspace(rootUri, rootPath) { + const seed = await fetchJson(`${BASE}/api/dev/seed`, { + method: "POST", + body: JSON.stringify({ + seeds: [ + { + kind: "setupWorkspace", + user_id: ACTOR_ID, + email: `${ACTOR_ID}@example.com`, + username: ACTOR_ID, + display_name: ACTOR_ID, + role: "admin", + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + workspace_id: `local-ws:${ACTOR_ID}:s7`, + workspace_name: "S7 target", + root_uri: rootUri, + root_path: rootPath, + source_kind: "local_folder", + permission: "write", + capabilities: ["ai"], + grant_source: "s7_target_smoke", + grant_created_by: ACTOR_ID, + }, + ], + }), + }); + if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { + throw new Error( + "S7 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", + ); + } + assert( + seed.status === 200 && seed.body.ok === true, + `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, + ); +} + +async function main() { + staticChecks(); + + fs.mkdirSync(ROOT, { recursive: true }); + const pagePath = "s7.md"; + fs.writeFileSync(path.join(ROOT, pagePath), "# S7 target package\n", "utf8"); + const rootUri = `file://${ROOT}`; + const workspaceId = `local-ws:${ACTOR_ID}:s7`; + const documentId = `local-doc:${workspaceId}:${pagePath}`; + + console.log(`\n🧪 S7 agent target package + dirty guard (base=${BASE})\n`); + + await seedWorkspace(rootUri, ROOT); + + const sessionId = `pi_lab_s7_${Date.now()}`; + const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, { + method: "POST", + body: JSON.stringify({ + sessionId, + rootUri, + workspaceId, + pagePath, + pageTitle: "S7 target", + }), + }); + assert(start.status === 200 && start.body.ok === true, `start: ${start.status} ${JSON.stringify(start.body)}`); + const sid = (start.body.session && start.body.session.sessionId) || sessionId; + + const targetPackage = { + schema: "mnote.agent_target_package.v1", + source: "s7_smoke", + frozenAt: Date.now(), + primaryTargetId: documentId, + workspaceId, + sourceKind: "local_folder", + rootUri, + documentId, + resourceKind: "page", + workspacePath: { + schema: "mnote.workspace_path.v1", + workspaceId, + sourceKind: "local_folder", + rootUri, + relativePath: pagePath, + documentId, + resourceKind: "page", + }, + currentFile: { + rootUri, + relativePath: pagePath, + documentId, + resourceKind: "page", + }, + allowedFiles: [pagePath], + targets: [ + { + targetId: documentId, + documentId, + workspaceId, + sourceKind: "local_folder", + rootUri, + relativePath: pagePath, + resourceKind: "page", + dirtyState: "clean", + policy: { + permission: "read_write", + writeRequiresCleanBuffer: true, + conflictPolicy: "fail_on_dirty_or_stale", + }, + }, + ], + policy: { + writeRequiresExplicitTarget: true, + allowedFilesSource: "selected_page_ai_target", + conflictPolicy: "fail_on_dirty_or_stale", + }, + }; + + // Server must accept targetPackage on send (deserialize, not 422). + const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, { + method: "POST", + body: JSON.stringify({ + sessionId: sid, + message: "S7 with target package", + rootUri, + workspaceId, + pagePath, + pageTitle: "S7 target", + targetPackage, + }), + }); + assert( + send.status === 200 && send.body.ok !== false, + `send with targetPackage: ${send.status} ${JSON.stringify(send.body)}`, + ); + assert( + send.body.accepted === true || send.body.sessionId || send.body.ok === true, + `send should accept prompt: ${JSON.stringify(send.body)}`, + ); + console.log(" send accepts targetPackage"); + + // buffer-state query shape (may 404 if no buffer opened — still valid API). + const bufferQs = new URLSearchParams({ + documentId, + sourceKind: "local_folder", + rootUri, + workspaceId, + relativePath: pagePath, + }); + const buffer = await fetchJson(`${BASE}/api/documents/buffer-state?${bufferQs.toString()}`); + // Not found is OK for never-opened buffer; found must include dirtyState when present. + if (buffer.status === 200 && buffer.body.ok === true) { + const result = buffer.body.result || buffer.body; + assert( + result.dirtyState !== undefined || result.dirty_state !== undefined || result.status, + `buffer-state payload missing dirty fields: ${JSON.stringify(buffer.body)}`, + ); + console.log(` buffer-state present dirtyState=${result.dirtyState || result.dirty_state}`); + } else { + console.log( + ` buffer-state ${buffer.status} (no open buffer — client gate uses snapshot dirty; OK)`, + ); + } + + // Client error code contract for UI. + const piLab = fs.readFileSync( + path.join(__dirname, "..", "rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js"), + "utf8", + ); + assert( + piLab.includes("page_ai_target_buffer_not_writable"), + "UI must surface page_ai_target_buffer_not_writable", + ); + console.log(" dirty conflict code page_ai_target_buffer_not_writable present"); + + console.log("\n✅ S7 agent target package + dirty guard smoke passed\n"); +} + +main().catch((err) => { + console.error("\n❌ S7 smoke failed:", err.message || err); + process.exit(1); +}); diff --git a/scripts/task-vault-single-folder-flatten-smoke.js b/scripts/task-vault-single-folder-flatten-smoke.js new file mode 100644 index 00000000..8350725b --- /dev/null +++ b/scripts/task-vault-single-folder-flatten-smoke.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); +const { execFileSync } = require("node:child_process"); + +const { BASE_URL, UI_TIMEOUT_MS, ensureAuthenticated, assert } = require("./tree-shell-smoke-helpers"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-vault-single-folder-")); +const ROOT_URI = `file://${ROOT}`; + +function authCookieHeader() { + const output = execFileSync(process.execPath, [path.join(__dirname, "mnote-vault-cli.js"), "auth-e2e"], { + cwd: path.resolve(__dirname, ".."), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const match = output.match(/^export MNOTE_COOKIE=(.+)$/m); + if (!match) throw new Error("auth-e2e 未返回 MNOTE_COOKIE"); + return JSON.parse(match[1]); +} + +function writeWorkspaceManifest() { + const metadataDir = path.join(ROOT, ".mnote"); + fs.mkdirSync(metadataDir, { recursive: true }); + fs.writeFileSync( + path.join(metadataDir, "workspace.json"), + `${JSON.stringify({ + workspaceId: "local-ws:mnote-e2e:vault-single-folder", + ownerId: "mnote-e2e", + createdAt: new Date().toISOString(), + capabilities: ["local_files", "markdown_edit", "vault"], + }, null, 2)}\n`, + "utf8", + ); +} + +async function requestJson(context, method, urlPath, data) { + const response = await context.fetch(`${BASE_URL}${urlPath}`, { + method, + headers: { "content-type": "application/json" }, + data, + timeout: UI_TIMEOUT_MS, + }); + const payload = await response.json(); + assert(response.ok(), `${urlPath} 请求失败: ${response.status()} ${JSON.stringify(payload)}`); + return payload.result || payload; +} + +async function main() { + writeWorkspaceManifest(); + const cookieHeader = authCookieHeader(); + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1180, height: 900 }, + locale: "zh-CN", + extraHTTPHeaders: { cookie: cookieHeader }, + }); + const page = await context.newPage(); + try { + await ensureAuthenticated(page, context.request); + await requestJson(context.request, "POST", "/api/vault/items", { + rootUri: ROOT_URI, + title: "单账号服务", + username: "solo-user", + password: process.env.MNOTE_VAULT_SMOKE_PASSWORD || "pw[A]", + folderPath: "个人/密码/单账号服务", + tags: ["temporary-test-tag"], + notesMarkdown: "## 临时说明\n\n仅用于 smoke。", + }); + await page.goto(`${BASE_URL}/vault?sourceKind=local_folder&rootUri=${encodeURIComponent(ROOT_URI)}`, { + waitUntil: "commit", + timeout: UI_TIMEOUT_MS, + }); + await page.locator('[data-testid="mnote-vault-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-testid="vault-list-item"]', { hasText: "单账号服务" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + const folderText = await page.locator('[data-testid="vault-list"]').innerText({ timeout: UI_TIMEOUT_MS }); + assert(!folderText.includes("单账号服务\n单账号服务"), "单账号文件夹不应再渲染成同名可折叠父节点 + 子条目"); + const singleFolderHeaders = await page.locator('[data-vault-toggle-folder]', { hasText: "单账号服务" }).count(); + assert(singleFolderHeaders === 0, `单账号文件夹不应出现可折叠标题,实际 ${singleFolderHeaders}`); + console.log(JSON.stringify({ ok: true, rootUri: ROOT_URI })); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exit(1); +}); diff --git a/scripts/task488-acp-multi-session-stability-smoke.js b/scripts/task488-acp-multi-session-stability-smoke.js deleted file mode 100644 index 9c625209..00000000 --- a/scripts/task488-acp-multi-session-stability-smoke.js +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs/promises"); -const path = require("node:path"); -const { chromium } = require("playwright"); -const { - BASE_URL, - REQUEST_TIMEOUT_MS, - UI_TIMEOUT_MS, - cleanupDocuments, - createTempDocument, - ensureAuthenticated, - renameDocument, - requestJson, -} = require("./tree-shell-smoke-helpers"); - -const OUT_DIR = path.join(process.cwd(), "tmp", "acp-multi-session-stability-smoke"); -const PROFILE = process.env.MNOTE_ACP_STABILITY_PROFILE || "reasonix"; -const RUNTIME = process.env.MNOTE_ACP_STABILITY_RUNTIME || ""; -const RUN_COUNT = Math.max(1, Number(process.env.MNOTE_ACP_STABILITY_RUN_COUNT || 3)); -const STREAM_TIMEOUT_MS = Number(process.env.MNOTE_ACP_STABILITY_STREAM_TIMEOUT_MS || 60_000); -const CANCEL_INDEX = Math.min(Math.max(0, Number(process.env.MNOTE_ACP_STABILITY_CANCEL_INDEX || 1)), RUN_COUNT - 1); - -function nowSuffix() { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -async function createHermesSession(request, target, suffix, index) { - return requestJson(request, "/api/hermes/client/sessions", { - method: "POST", - headers: { "x-mnote-actor-id": "acp-smoke-user" }, - data: { - workspaceId: target.workspaceId, - documentId: target.documentId, - title: `ACP 稳定性 ${suffix} #${index + 1}`, - profile: PROFILE, - traceId: `trace_acp_session_${suffix}_${index}`, - }, - }); -} - -async function createHermesRun(request, target, sessionId, suffix, index) { - const payload = { - workspaceId: target.workspaceId, - documentId: target.documentId, - sessionId, - profile: PROFILE, - message: - index === CANCEL_INDEX - ? `请用中文逐条列出 1 到 200 的检查项,每条都包含 ACP 多会话 smoke ${suffix},不要修改文件。` - : `请用一句中文回复:ACP 多会话 smoke ${suffix} 第 ${index + 1} 条。不要修改文件。`, - contextScope: "summary", - traceId: `trace_acp_run_${suffix}_${index}`, - actorId: "acp-smoke-user", - }; - if (RUNTIME) { - payload.acpRuntime = RUNTIME; - } - return requestJson(request, "/api/hermes/client/runs", { - method: "POST", - headers: { "x-mnote-actor-id": "acp-smoke-user" }, - data: payload, - }); -} - -async function abortRun(request, runId) { - return requestJson(request, `/api/hermes/client/runs/${encodeURIComponent(runId)}/abort`, { - method: "POST", - headers: { "x-mnote-actor-id": "acp-smoke-user" }, - data: { reason: "task488_acp_stability_smoke_cancel" }, - }); -} - -function parseSseChunk(text, events) { - for (const frame of text.split(/\n\n+/)) { - const lines = frame.split(/\n/).filter(Boolean); - if (lines.length === 0) continue; - let event = "message"; - const dataLines = []; - for (const line of lines) { - if (line.startsWith("event:")) { - event = line.slice("event:".length).trim() || "message"; - } else if (line.startsWith("data:")) { - dataLines.push(line.slice("data:".length).trimStart()); - } - } - if (dataLines.length === 0) continue; - const dataText = dataLines.join("\n"); - let data = dataText; - try { - data = JSON.parse(dataText); - } catch { - // 保留原始文本,便于排障。 - } - events.push({ event, data }); - } -} - -async function cookieHeaderForContext(context) { - const cookies = await context.cookies(BASE_URL); - return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); -} - -async function streamRunEvents(runId, label, cookieHeader) { - const events = []; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS); - try { - const response = await fetch(`${BASE_URL}/api/hermes/client/events/${encodeURIComponent(runId)}`, { - headers: { - "x-mnote-actor-id": "acp-smoke-user", - ...(cookieHeader ? { cookie: cookieHeader } : {}), - }, - signal: controller.signal, - }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`${label} events 请求失败: ${response.status} ${response.statusText} ${text.slice(0, 1200)}`); - } - const decoder = new TextDecoder(); - const reader = response.body.getReader(); - let raw = ""; - let pending = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value, { stream: true }); - raw += chunk; - pending += chunk; - const frames = pending.split(/\n\n+/); - pending = frames.pop() || ""; - if (frames.length > 0) { - parseSseChunk(frames.join("\n\n"), events); - } - if (terminalEvent(events)) { - await reader.cancel().catch(() => undefined); - break; - } - } - const rest = decoder.decode(); - if (rest) { - raw += rest; - pending += rest; - } - if (pending.trim()) { - parseSseChunk(pending, events); - } - if (!terminalEvent(events)) { - throw new Error(`${label} events 未收到 terminal event`); - } - return { runId, status: response.status, events, raw }; - } finally { - clearTimeout(timeout); - controller.abort(); - } -} - -function terminalEvent(events) { - return events.find((entry) => entry.event === "run.completed" || entry.event === "run.failed" || entry.event === "run.aborted"); -} - -async function main() { - const suffix = nowSuffix(); - const title = `TEST-ACP-STABILITY-${suffix}`; - const createdIds = []; - const evidence = { - ok: false, - baseUrl: BASE_URL, - profile: PROFILE, - runtime: RUNTIME || null, - runCount: RUN_COUNT, - cancelIndex: CANCEL_INDEX, - title, - sessions: [], - runs: [], - streams: [], - }; - await fs.mkdir(OUT_DIR, { recursive: true }); - - const browser = await chromium.launch({ - headless: true, - args: ["--no-sandbox", "--disable-dev-shm-usage"], - }); - const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); - const page = await context.newPage(); - - try { - await ensureAuthenticated(page, context.request); - const target = await createTempDocument(context.request); - createdIds.push(target.documentId); - evidence.documentId = target.documentId; - evidence.workspaceId = target.workspaceId; - await renameDocument(context.request, target.workspaceId, target.documentId, title); - - const sessions = []; - for (let index = 0; index < RUN_COUNT; index += 1) { - const session = await createHermesSession(context.request, target, suffix, index); - assert(session.sessionId, `session ${index + 1} 缺少 sessionId`); - sessions.push(session); - evidence.sessions.push({ - index, - sessionId: session.sessionId, - persistence: session.persistence || null, - profile: session.profile || PROFILE, - }); - } - - const runs = await Promise.all( - sessions.map((session, index) => createHermesRun(context.request, target, session.sessionId, suffix, index)), - ); - for (let index = 0; index < runs.length; index += 1) { - assert(runs[index].runId, `run ${index + 1} 缺少 runId`); - evidence.runs.push({ - index, - runId: runs[index].runId, - sessionId: runs[index].sessionId, - persistence: runs[index].persistence || null, - runtime: runs[index].runtime || null, - }); - } - - const cookieHeader = await cookieHeaderForContext(context); - const streamPromises = runs.map((run, index) => streamRunEvents(run.runId, `run ${index + 1}`, cookieHeader)); - await new Promise((resolve) => setTimeout(resolve, 500)); - const abortPayload = await abortRun(context.request, runs[CANCEL_INDEX].runId); - evidence.abort = { - runId: runs[CANCEL_INDEX].runId, - status: abortPayload.status, - ok: abortPayload.ok, - events: abortPayload.events || [], - }; - - const streamResults = await Promise.allSettled(streamPromises); - const rejectedStreams = streamResults - .map((result, index) => ({ result, index })) - .filter(({ result }) => result.status === "rejected"); - if (rejectedStreams.length > 0) { - evidence.streamErrors = rejectedStreams.map(({ result, index }) => ({ - index, - runId: runs[index].runId, - message: result.reason instanceof Error ? result.reason.message : String(result.reason), - stack: result.reason instanceof Error ? result.reason.stack : null, - })); - throw new Error(`ACP SSE 读取失败: ${evidence.streamErrors.map((item) => `run ${item.index + 1}: ${item.message}`).join("; ")}`); - } - const streams = streamResults.map((result) => result.value); - for (let index = 0; index < streams.length; index += 1) { - const stream = streams[index]; - const terminal = terminalEvent(stream.events); - evidence.streams.push({ - index, - runId: stream.runId, - eventCount: stream.events.length, - eventNames: stream.events.map((entry) => entry.event), - terminalEvent: terminal ? terminal.event : null, - }); - await fs.writeFile( - path.join(OUT_DIR, `events-${index + 1}-${stream.runId}.json`), - JSON.stringify(stream.events, null, 2), - "utf8", - ); - assert(terminal, `run ${index + 1} 必须收到 run.completed、run.failed 或 run.aborted`); - if (index === CANCEL_INDEX) { - assert.equal(terminal.event, "run.aborted", "被 abort 的 run 必须以 run.aborted 结束"); - } - } - - assert.equal(new Set(evidence.sessions.map((item) => item.sessionId)).size, RUN_COUNT, "sessionId 必须互相隔离"); - assert.equal(new Set(evidence.runs.map((item) => item.runId)).size, RUN_COUNT, "runId 必须互相隔离"); - assert(evidence.abort.ok === true, "abort API 必须返回 ok=true"); - evidence.ok = true; - await fs.writeFile(path.join(OUT_DIR, "result.json"), JSON.stringify(evidence, null, 2), "utf8"); - console.log(JSON.stringify(evidence, null, 2)); - } finally { - await cleanupDocuments(context.request, createdIds).catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } -} - -main().catch(async (error) => { - await fs.mkdir(OUT_DIR, { recursive: true }).catch(() => undefined); - await fs - .writeFile( - path.join(OUT_DIR, "error.json"), - JSON.stringify( - { - ok: false, - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : null, - }, - null, - 2, - ), - "utf8", - ) - .catch(() => undefined); - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task490-runtime-surfaces-smoke.js b/scripts/task490-runtime-surfaces-smoke.js index 78ce9422..6c0e1a1c 100644 --- a/scripts/task490-runtime-surfaces-smoke.js +++ b/scripts/task490-runtime-surfaces-smoke.js @@ -282,6 +282,11 @@ async function main() { const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); assert(response, "文档页没有返回响应"); assert(response.status() === 200, `文档页状态码异常: ${response.status()}`); + + const editModeToggle = page.locator('[data-document-edit-mode-toggle]').first(); + if (await editModeToggle.isVisible({ timeout: 3_000 }).catch(() => false)) { + await editModeToggle.click({ timeout: UI_TIMEOUT_MS }); + } const editor = await waitForRuntimeIsland(page, UI_TIMEOUT_MS); const moreButton = page.locator('[data-testid="wolai-page-settings-trigger"]').first(); diff --git a/scripts/task544-weknora-provider-bridge-static-smoke.js b/scripts/task544-weknora-provider-bridge-static-smoke.js deleted file mode 100644 index 7931bf47..00000000 --- a/scripts/task544-weknora-provider-bridge-static-smoke.js +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); - -const ROOT = process.cwd(); -const OUTPUT_DIR = path.join(ROOT, "tmp", "task544-weknora-provider-bridge-static-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); - -function read(relativePath) { - return fs.readFileSync(path.join(ROOT, relativePath), "utf8"); -} - -function assertContains(content, needle, label) { - assert(content.includes(needle), `${label} 缺少: ${needle}`); -} - -function assertNotContains(content, needle, label) { - assert(!content.includes(needle), `${label} 不应包含: ${needle}`); -} - -function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - - const route = read("rust/crates/mnote-web/src/routes/knowledge_rag.rs"); - const hermesKnowledge = read("rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs"); - const manifest = read("rust/crates/mnote-web/src/hermes_tools/manifest.rs"); - const toolRoute = read("rust/crates/mnote-web/src/routes/hermes_tools.rs"); - const ui = read("rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js"); - - assertContains(route, 'const DEFAULT_KNOWLEDGE_PROVIDER: &str = "weknora";', "provider boundary"); - assertContains(route, 'const LEGACY_KNOWLEDGE_PROVIDER: &str = "lightrag_legacy";', "legacy provider boundary"); - assertContains(route, '"/knowledge-search"', "WeKnora endpoint"); - assertContains(route, '"knowledge_base_ids"', "WeKnora request shape"); - assertContains(route, '"knowledge_ids"', "WeKnora request shape"); - assertContains(route, '"match_count"', "WeKnora request shape"); - assertContains(route, '"providerConfig": knowledge_provider_config()', "status provider config"); - assertContains(route, '"health": weknora_health(&context).await', "WeKnora health"); - assertContains(route, '"registry": registry', "status registry"); - assertContains(route, '"providerIds"', "WeKnora reference mapping"); - assertContains(route, '"providerFilenameIsNotLocalPath": true', "WeKnora locator diagnostics"); - assertContains(route, '"locatorDegraded": locator_degraded', "WeKnora degraded locator"); - assertContains(route, '"reference": chunk', "WeKnora raw reference retention"); - assertContains(route, 'Value::Null', "WeKnora filePath null guard"); - assertContains(route, '"chunkMetadata"', "WeKnora metadata retention"); - assertContains(route, '"imageInfo"', "WeKnora image info retention"); - assertContains(route, '"parentChunk"', "WeKnora parent chunk retention"); - assertContains(route, '"subChunks"', "WeKnora sub chunk retention"); - assertContains(route, "ingest_weknora_registry_only", "WeKnora ingest branch"); - assertContains(route, "upload_weknora_file", "WeKnora provider upload"); - assertContains(route, '/knowledge-bases/{}/knowledge/file', "WeKnora file ingest endpoint"); - assertContains(route, 'reqwest::multipart::Form::new()', "WeKnora multipart upload"); - assertContains(route, '"fileName"', "WeKnora preserves root relative path"); - assertContains(route, '"mnote_source_root_relative_path"', "WeKnora source metadata"); - assertContains(route, '"scanSkipped": false', "WeKnora ingest uses provider upload"); - assertContains(route, '"scanSkipReason": "weknora_provider_direct_upload"', "WeKnora ingest no LightRAG scan"); - assertContains(route, '"pending_provider_ingest_missing_kb"', "WeKnora ingest missing kb pending"); - assertContains(route, '"pending_provider_ingest"', "WeKnora ingest provider pending"); - assertContains(route, '"registry_only"', "WeKnora ingest registry mapping"); - assertContains(route, '"provider_mapped"', "WeKnora provider id mapping"); - assertContains(route, "delete_weknora_registry_source", "WeKnora delete branch"); - assertContains(route, '"providerDeleteAttempted": false', "WeKnora delete no provider call"); - assertContains(route, '"localFileDeleted": false', "WeKnora delete keeps local file"); - assertContains(route, '"pending_provider_delete"', "WeKnora delete pending provider marker"); - assertContains(route, "weknora_section_context_payload", "WeKnora section-context branch"); - assertContains(route, '"sidecarRead": false', "WeKnora section-context no LightRAG sidecar"); - assertContains(route, '"degradedReason": "weknora_local_source_context_without_provider_chunks"', "WeKnora section-context degraded local fallback"); - assertContains(route, 'if active_knowledge_provider() == DEFAULT_KNOWLEDGE_PROVIDER {\n return ingest_weknora_registry_only', "WeKnora ingest bypasses LightRAG staging"); - assertContains(route, 'if active_knowledge_provider() == DEFAULT_KNOWLEDGE_PROVIDER {\n return delete_weknora_registry_source', "WeKnora delete bypasses LightRAG delete"); - assertContains(route, "let payload = weknora_section_context_payload", "WeKnora section-context bypasses sidecar"); - - for (const toolName of [ - "mnote.weknora.search", - "mnote.weknora.list_sources", - "mnote.weknora.get_source_status", - "mnote.weknora.open_reference", - ]) { - assertContains(manifest, `"${toolName}"`, "manifest"); - assertContains(toolRoute, `"${toolName}"`, "tool dispatch"); - } - assertContains(manifest, '"scope"', "WeKnora tool scope field"); - assertContains(manifest, '"allowlist"', "WeKnora tool allowlist field"); - assertContains(manifest, '"allowedRoots"', "WeKnora tool allowedRoots field"); - assertContains(manifest, '"aiAccessScope"', "WeKnora tool aiAccessScope field"); - assertContains(manifest, '"sourcePaths"', "WeKnora tool sourcePaths field"); - assertContains(hermesKnowledge, "mnote_weknora_scope_required", "WeKnora tool scope guard"); - - assertNotContains(ui, "打开 LightRAG", "UI default provider copy"); - assertNotContains(ui, "正在读取 LightRAG 状态", "UI default provider copy"); - assertNotContains(ui, "无法连接 LightRAG 服务", "UI default provider copy"); - assertContains(ui, "WeKnora", "UI provider label"); - assertContains(ui, "知识库 provider", "UI neutral copy"); - - const result = { - ok: true, - checkedFiles: [ - "rust/crates/mnote-web/src/routes/knowledge_rag.rs", - "rust/crates/mnote-web/src/hermes_tools/knowledge_rag.rs", - "rust/crates/mnote-web/src/hermes_tools/manifest.rs", - "rust/crates/mnote-web/src/routes/hermes_tools.rs", - "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js", - ], - provider: "weknora", - assertions: { - providerBoundary: true, - providerIds: true, - locatorDegraded: true, - manifestFacade: true, - scopeFields: true, - uiNoDefaultLightRagCopy: true, - ingestNoLightRagScan: true, - deleteNoLightRagDelete: true, - sectionContextNoSidecar: true, - }, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); -} - -main(); diff --git a/scripts/task558-page-ai-reasonix-live-session-context-smoke.js b/scripts/task558-page-ai-reasonix-live-session-context-smoke.js deleted file mode 100644 index 02beacd2..00000000 --- a/scripts/task558-page-ai-reasonix-live-session-context-smoke.js +++ /dev/null @@ -1,248 +0,0 @@ -#!/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 ROOT = path.resolve(__dirname, ".."); -const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers")); -const { - setupWorkspaceAccess, - getAiRuntimeRun, - countAiRuntimeEvents, -} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed")); -const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; -const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_LIVE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task558-reasonix-live-")); -const ACTOR = "mnote-e2e"; -const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; - -async function signIn(context) { - const response = await context.request.fetch(`${BASE_URL}/api/auth`, { - method: "POST", - headers: { "content-type": "application/json" }, - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { account: ACTOR, password: PASSWORD, flow: "signIn" }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - const text = await response.text(); - assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); - const whoami = await context.request.fetch(`${BASE_URL}/api/auth/whoami`, { timeout: UI_TIMEOUT_MS }); - const whoamiText = await whoami.text(); - assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`); - return JSON.parse(whoamiText); -} - -async function selectReasonix(page) { - await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix", - null, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function newestAssistantText(page, beforeCount) { - return await page.evaluate((countBefore) => { - const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')); - const node = nodes.slice(countBefore).at(-1) || nodes.at(-1); - return node?.querySelector(".wolai-page-ai-message-text")?.textContent || ""; - }, beforeCount); -} - -function normalize(text) { - return String(text || "").replace(/\s+/g, "").trim(); -} - -async function sendPrompt(page, prompt, expectedCompact) { - const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count(); - await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""), - null, - { timeout: Math.max(UI_TIMEOUT_MS, 120_000) }, - ); - const status = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""); - assert.equal(status, "completed", `Page AI run 应完成,实际 status=${status}`); - await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: UI_TIMEOUT_MS }); - const text = await newestAssistantText(page, before); - assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`); - const runId = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-id") || ""); - assert(runId, "缺少 data-mnote-page-ai-run-id"); - return { runId, text }; -} - -async function waitUntil(label, predicate, timeoutMs = UI_TIMEOUT_MS) { - const start = Date.now(); - let last = null; - while (Date.now() - start < timeoutMs) { - last = await predicate().catch((error) => error); - if (last === true) return; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`${label}_timeout: ${String(last && last.message || last || '')}`); -} - -async function main() { - fs.mkdirSync(OUT_DIR, { recursive: true }); - const suffix = Date.now().toString(36); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task558-reasonix-live-")); - const rootUri = `file://${root}`; - const relativePath = `Task558-${suffix}.md`; - const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`; - - const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - const capturedRuns = []; - page.on("request", (request) => { - if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return; - try { - capturedRuns.push(JSON.parse(request.postData() || "{}")); - } catch { - capturedRuns.push({ raw: request.postData() || "" }); - } - }); - - try { - const viewer = await signIn(context); - const actorId = viewer.userId || ACTOR; - const workspaceId = `local-ws:${actorId}:task558-${suffix}`; - fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); - fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8"); - fs.writeFileSync(path.join(root, relativePath), `# Task558 Reasonix Live\n\n${suffix}\n`, "utf8"); - // 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite - await setupWorkspaceAccess(context.request, BASE_URL, { - actorId, - email: `${actorId}@example.com`, - username: actorId, - displayName: actorId, - role: "user", - workspaceId, - workspaceName: "Task558 Reasonix Live", - rootUri, - rootPath: root, - sourceKind: "local_folder", - permission: "write", - capabilities: ["ai"], - grantSource: "smoke", - grantCreatedBy: actorId, - timeoutMs: UI_TIMEOUT_MS, - }); - const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`); - url.searchParams.set("sourceKind", "local_folder"); - url.searchParams.set("rootUri", rootUri); - url.searchParams.set("workspaceId", workspaceId); - await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await selectReasonix(page); - - const firstMarker = `TASK558_FIRST_${suffix}`.toUpperCase(); - const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase(); - const beforeAssistantCount = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count(); - await page.locator("[data-page-ai-input]").fill( - `Reasonix 上下文连续性测试:请记住如果下一轮用户只说"可以",你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`, - { timeout: UI_TIMEOUT_MS }, - ); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => ["queued", "running", "tool_calling"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""), - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.locator("[data-page-ai-input]").fill("可以", { timeout: UI_TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => Boolean(document.querySelector('[data-page-ai-queue-item]')), - null, - { timeout: UI_TIMEOUT_MS }, - ); - const queuedPreview = await page.locator('[data-page-ai-queue-item]').first().textContent({ timeout: UI_TIMEOUT_MS }); - assert(String(queuedPreview || '').includes("可以"), `queued preview 应包含第二轮短回复: ${queuedPreview}`); - await waitUntil("reasonix_two_runs_started", async () => capturedRuns.length >= 2, Math.max(UI_TIMEOUT_MS, 120_000)); - await page.waitForFunction( - ([firstNeedle, secondNeedle]) => { - const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || ""; - return text.includes(firstNeedle) - && text.includes(secondNeedle) - && ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "") - && !document.querySelector('[data-page-ai-streaming="true"]'); - }, - [firstMarker, secondMarker], - { timeout: Math.max(UI_TIMEOUT_MS, 120_000) }, - ); - const assistantTexts = await page.evaluate((countBefore) => { - return Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')) - .slice(countBefore) - .map((node) => node.querySelector(".wolai-page-ai-message-text")?.textContent || ""); - }, beforeAssistantCount); - assert(assistantTexts.some((text) => normalize(text) === firstMarker), `第一轮回复缺失: ${JSON.stringify(assistantTexts)}`); - assert(assistantTexts.some((text) => normalize(text) === secondMarker), `第二轮回复缺失: ${JSON.stringify(assistantTexts)}`); - assert.equal(capturedRuns.length, 2, `应捕获两次 Page AI run,实际 ${capturedRuns.length}`); - assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP"); - assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId"); - - // 通过 API helper + capturedRuns 验证 DB 持久化和 acpSessionId 连续性,不再直读 SQLite - const firstRunId = capturedRuns[0].runId; - const secondRunId = capturedRuns[1].runId; - const first = { runId: firstRunId, text: firstMarker }; - const second = { runId: secondRunId, text: secondMarker }; - - const firstRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: firstRunId }); - const secondRun = await getAiRuntimeRun(context.request, BASE_URL, { userId: actorId, runId: secondRunId }); - assert(firstRun, `first run ${firstRunId} 应 persist`); - assert(secondRun, `second run ${secondRunId} 应 persist`); - - const infoCount1 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: firstRunId, eventType: "session.info.updated" }); - const infoCount2 = await countAiRuntimeEvents(context.request, BASE_URL, { userId: actorId, runId: secondRunId, eventType: "session.info.updated" }); - assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`); - assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`); - - const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean); - assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(acpSessionIds)}`); - assert.equal(capturedRuns[1].acpSessionId, acpSessionIds[0], "第二轮请求 acpSessionId 应等于 live session id"); - - const screenshotPath = path.join(OUT_DIR, "task558-reasonix-live.png"); - await page.screenshot({ path: screenshotPath, fullPage: true }); - const result = { - ok: true, - baseUrl: BASE_URL, - outDir: OUT_DIR, - root, - rootUri, - workspaceId, - documentId, - first, - second, - queuedPreview, - acpSessionId: acpSessionIds[0], - capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })), - screenshotPath, - }; - fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); - } catch (error) { - await page.screenshot({ path: path.join(OUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined); - fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), capturedRuns }, null, 2)}\n`, "utf8"); - throw error; - } finally { - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - if (process.env.MNOTE_KEEP_TASK558_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true }); - } -} - -main().catch((error) => { - console.error(error && error.stack ? error.stack : error); - process.exit(1); -}); diff --git a/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js b/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js deleted file mode 100644 index c27dce6c..00000000 --- a/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const http = require("node:http"); -const net = require("node:net"); -const os = require("node:os"); -const path = require("node:path"); -const { spawn } = require("node:child_process"); -const { chromium } = require("playwright"); - -const ROOT = path.resolve(__dirname, ".."); -const { - setupWorkspaceAccess, - getAiRuntimeRun, - countAiRuntimeEvents, -} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed")); -const OUT_DIR = process.env.MNOTE_PAGE_AI_TERMINAL_STATUS_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task559-terminal-status-")); -const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000); -const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; -const ACTOR = "mnote-e2e"; -const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; - -function pickPort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = address && typeof address === "object" ? address.port : 0; - server.close(() => resolve(port)); - }); - server.on("error", reject); - }); -} - -function waitForHttpOk(url, timeoutMs) { - const deadline = Date.now() + timeoutMs; - return new Promise((resolve, reject) => { - const tick = () => { - const request = http.get(url, (response) => { - response.resume(); - if (response.statusCode >= 200 && response.statusCode < 500) return resolve(); - retry(); - }); - request.on("error", retry); - request.setTimeout(1000, () => { request.destroy(); retry(); }); - }; - const retry = () => { - if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`)); - setTimeout(tick, 250); - }; - tick(); - }); -} - -function writeFakeReasonixAcp(scriptPath) { - fs.writeFileSync(scriptPath, ` -import * as readline from 'node:readline'; -import { stdin as input, stdout as output } from 'node:process'; -const rl = readline.createInterface({ input, output, terminal: false }); -let nextId = 1; -function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); } -function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); } -function markerFrom(text) { return (String(text || '').match(/TASK559_[A-Z0-9_]+/) || ['TASK559_FALLBACK'])[0]; } -rl.on('line', (line) => { - const msg = JSON.parse(line); - if (!msg.id) return; - if (msg.method === 'initialize') { - send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task559-fake-reasonix', version: '1.0' }, authMethods: [] } }); - } else if (msg.method === 'session/new') { - send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task559_reasonix_session_' + nextId++ } }); - } else if (msg.method === 'session/prompt') { - const sessionId = msg.params?.sessionId || ''; - const marker = markerFrom(promptText(msg.params?.prompt)); - for (let i = 0; i < 600; i += 1) { - send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'thought_chunk', content: { type: 'text', text: 'thought-' + i + '\\n' } } } }); - } - send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } }); - send({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } }); - } -}); -`, "utf8"); -} - -async function signIn(context, baseUrl) { - const response = await context.request.fetch(`${baseUrl}/api/auth`, { - method: "POST", - headers: { "content-type": "application/json" }, - data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } }, - timeout: TIMEOUT_MS, - }); - assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${await response.text()}`); - const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS }); - return JSON.parse(await whoami.text()); -} - -async function selectReasonix(page) { - await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS }); - await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS }); -} - -async function main() { - fs.mkdirSync(OUT_DIR, { recursive: true }); - const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs"); - writeFakeReasonixAcp(fakeScript); - const port = await pickPort(); - const baseUrl = `http://127.0.0.1:${port}`; - const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], title: "Reasonix Fake" }]); - const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { - cwd: path.join(ROOT, "rust"), - env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task559-terminal-status-")); - let browser; - try { - await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS); - browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - const viewer = await signIn(context, baseUrl); - const actorId = viewer.userId || ACTOR; - const suffix = Date.now().toString(36).toUpperCase(); - const workspaceId = `local-ws:${actorId}:task559-${suffix.toLowerCase()}`; - const rootUri = `file://${root}`; - const relativePath = `Task559-${suffix}.md`; - const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`; - fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); - fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8"); - fs.writeFileSync(path.join(root, relativePath), `# Task559 Terminal Status\n\n${suffix}\n`, "utf8"); - // 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite - await setupWorkspaceAccess(context.request, baseUrl, { - actorId, - email: `${actorId}@example.com`, - username: actorId, - displayName: actorId, - role: "user", - workspaceId, - workspaceName: "Task559 Terminal Status", - rootUri, - rootPath: root, - sourceKind: "local_folder", - permission: "write", - capabilities: ["ai"], - grantSource: "smoke", - grantCreatedBy: actorId, - timeoutMs: TIMEOUT_MS, - }); - const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`); - url.searchParams.set("sourceKind", "local_folder"); - url.searchParams.set("rootUri", rootUri); - url.searchParams.set("workspaceId", workspaceId); - await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); - await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS }); - await selectReasonix(page); - const marker = `TASK559_DONE_${suffix}`; - await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS }); - await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS }); - await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS }); - const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || ''); - assert(runId, "缺少 runId"); - const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS }); - assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker); - const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS }); - assert(String(runtimeStrip || '').includes('reasonix'), `runtime strip 应显示 reasonix: ${runtimeStrip}`); - assert(!String(runtimeStrip || '').includes('输出中'), `完成后 runtime strip 不应残留输出中: ${runtimeStrip}`); - // 通过 API helper 验证 run 持久化状态,不再直读 SQLite - const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId }); - assert(persistedRun, `run ${runId} 应 persist`); - assert.equal(persistedRun.status, "completed", "API run status 应为 completed"); - const terminalEventCount = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId, eventType: "run.completed" }); - assert(Number(terminalEventCount) >= 1, "应持久化 run.completed"); - const screenshotPath = path.join(OUT_DIR, "task559-terminal-status.png"); - await page.screenshot({ path: screenshotPath, fullPage: true }); - await page.locator('[data-page-ai-runtime-strip]').click({ timeout: TIMEOUT_MS }); - await page.waitForFunction(() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'status', null, { timeout: TIMEOUT_MS }); - await page.waitForFunction(() => { - const detail = document.querySelector('[data-page-ai-runtime-detail]')?.textContent || ''; - const jobs = document.querySelector('[data-page-ai-jobs-list]')?.textContent || ''; - const logs = document.querySelector('[data-page-ai-logs-list]')?.textContent || ''; - const usage = document.querySelector('[data-page-ai-usage-detail]')?.textContent || ''; - return detail.includes('runtime') && detail.includes('MCP/tools') && jobs.length > 0 && logs.length > 0 && usage.length > 0; - }, null, { timeout: TIMEOUT_MS }); - const statusText = await page.locator('[data-page-ai-panel="status"]').textContent({ timeout: TIMEOUT_MS }); - assert(String(statusText || '').includes('Current runtime'), '状态页应显示 Current runtime'); - assert(String(statusText || '').includes('Queue'), '状态页应显示 Queue'); - assert(String(statusText || '').includes('Jobs'), '状态页应显示 Jobs'); - assert(String(statusText || '').includes('Logs tail'), '状态页应显示 Logs tail'); - assert(String(statusText || '').includes('Usage'), '状态页应显示 Usage'); - const runtimePanelScreenshotPath = path.join(OUT_DIR, "task559-runtime-panel.png"); - await page.screenshot({ path: runtimePanelScreenshotPath, fullPage: true }); - await page.setViewportSize({ width: 452, height: 900 }); - await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS }); - const mobileTabs = await page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]').evaluateAll((nodes) => nodes.map((node) => { - const rect = node.getBoundingClientRect(); - return { text: node.textContent.trim(), box: { width: rect.width, height: rect.height } }; - })); - assert.deepEqual(mobileTabs.map((tab) => tab.text), ['MNote', 'Reasonix', 'Hermes'], `移动宽度设置 tabs 应保持三层: ${JSON.stringify(mobileTabs)}`); - assert(mobileTabs.every((tab) => tab.box.width > 40 && tab.box.height > 24), `移动宽度 tabs 不应挤压不可点: ${JSON.stringify(mobileTabs)}`); - const mobileSettingsScreenshotPath = path.join(OUT_DIR, "task559-settings-mobile.png"); - await page.screenshot({ path: mobileSettingsScreenshotPath, fullPage: true }); - const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath, runtimePanelScreenshotPath, mobileSettingsScreenshotPath }; - fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); - await context.close().catch(() => undefined); - } catch (error) { - fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, "utf8"); - throw error; - } finally { - if (browser) await browser.close().catch(() => undefined); - server.kill("SIGTERM"); - await new Promise((resolve) => server.once("exit", resolve)); - if (process.env.MNOTE_KEEP_TASK559_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true }); - } -} - -main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); }); diff --git a/scripts/task560-page-ai-hermes-load-replay-smoke.js b/scripts/task560-page-ai-hermes-load-replay-smoke.js deleted file mode 100644 index 806b2c4a..00000000 --- a/scripts/task560-page-ai-hermes-load-replay-smoke.js +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const http = require("node:http"); -const net = require("node:net"); -const os = require("node:os"); -const path = require("node:path"); -const { spawn } = require("node:child_process"); -const { chromium } = require("playwright"); - -const ROOT = path.resolve(__dirname, ".."); -const { - setupWorkspaceAccess, - getAiRuntimeRun, - countAiRuntimeEvents, -} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed")); -const OUT_DIR = process.env.MNOTE_PAGE_AI_HERMES_LOAD_REPLAY_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task560-hermes-load-replay-")); -const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000); -const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; -const ACTOR = "mnote-e2e"; -const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; - -function pickPort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = address && typeof address === "object" ? address.port : 0; - server.close(() => resolve(port)); - }); - server.on("error", reject); - }); -} - -function waitForHttpOk(url, timeoutMs) { - const deadline = Date.now() + timeoutMs; - return new Promise((resolve, reject) => { - const tick = () => { - const request = http.get(url, (response) => { - response.resume(); - if (response.statusCode >= 200 && response.statusCode < 500) return resolve(); - retry(); - }); - request.on("error", retry); - request.setTimeout(1000, () => { request.destroy(); retry(); }); - }; - const retry = () => { - if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`)); - setTimeout(tick, 250); - }; - tick(); - }); -} - -function writeFakeHermesAcp(scriptPath) { - fs.writeFileSync(scriptPath, ` -import * as readline from 'node:readline'; -import { stdin as input, stdout as output } from 'node:process'; -import fs from 'node:fs'; -const rl = readline.createInterface({ input, output, terminal: false }); -const replay = process.env.MNOTE_TASK560_HERMES_REPLAY === '1'; -const logPath = process.env.MNOTE_TASK560_FAKE_LOG || ''; -let nextId = 1; -function log(entry) { if (logPath) fs.appendFileSync(logPath, JSON.stringify({ ...entry, at: Date.now() }) + '\\n'); } -function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); } -function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); } -function markerFrom(text) { return (String(text || '').match(/TASK560_[A-Z0-9_]+/) || ['TASK560_FALLBACK'])[0]; } -rl.on('line', (line) => { - const msg = JSON.parse(line); - if (!msg.id) return; - if (msg.method === 'initialize') { - log({ method: msg.method }); - send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: true }, agentInfo: { name: 'task560-fake-hermes', version: '1.0' }, authMethods: [] } }); - } else if (msg.method === 'session/new') { - const sessionId = 'task560_hermes_session_' + nextId++; - log({ method: msg.method, sessionId }); - send({ jsonrpc: '2.0', id: msg.id, result: { sessionId } }); - } else if (msg.method === 'session/load') { - const sessionId = msg.params?.sessionId || 'missing_session'; - log({ method: msg.method, sessionId, replay }); - if (replay) { - send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'TASK560_REPLAY_SHOULD_NOT_APPEAR' } } } }); - } - send({ jsonrpc: '2.0', id: msg.id, result: { sessionId } }); - } else if (msg.method === 'session/prompt') { - const sessionId = msg.params?.sessionId || ''; - const marker = markerFrom(promptText(msg.params?.prompt)); - log({ method: msg.method, sessionId, marker }); - send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } }); - send({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } }); - } -}); -`, "utf8"); -} - -async function signIn(context, baseUrl) { - const response = await context.request.fetch(`${baseUrl}/api/auth`, { - method: "POST", - headers: { "content-type": "application/json" }, - data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } }, - timeout: TIMEOUT_MS, - }); - const text = await response.text(); - assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); - const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS }); - const whoamiText = await whoami.text(); - assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`); - return JSON.parse(whoamiText); -} - -async function selectHermes(page) { - await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS }); - await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-agent-id="hermes"]').first().click({ timeout: TIMEOUT_MS }); - await page.waitForFunction( - () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-mnote-acp-runtime') === 'hermes', - null, - { timeout: TIMEOUT_MS }, - ); -} - -function normalize(text) { - return String(text || "").replace(/\s+/g, "").trim(); -} - -async function newestAssistantText(page, beforeCount) { - return await page.evaluate((countBefore) => { - const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')); - const node = nodes.slice(countBefore).at(-1) || nodes.at(-1); - return node?.querySelector('.wolai-page-ai-message-text')?.textContent || ''; - }, beforeCount); -} - -async function sendPrompt(page, prompt, expectedCompact) { - const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count(); - await page.locator('[data-page-ai-input]').fill(prompt, { timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS }); - await page.waitForFunction( - () => ['completed', 'failed', 'aborted'].includes(document.documentElement.getAttribute('data-mnote-page-ai-run-status') || ''), - null, - { timeout: Math.max(TIMEOUT_MS, 120_000) }, - ); - const status = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') || ''); - assert.equal(status, 'completed', `Page AI run 应完成,实际 status=${status}`); - await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS }); - const text = await newestAssistantText(page, before); - assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`); - assert(!normalize(text).includes('TASK560_REPLAY_SHOULD_NOT_APPEAR'), 'Hermes replay 不应出现在当前 assistant 正文'); - const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || ''); - assert(runId, '缺少 data-mnote-page-ai-run-id'); - return { runId, text }; -} - -function readFakeLog(logPath) { - if (!fs.existsSync(logPath)) return []; - return fs.readFileSync(logPath, 'utf8').trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line)); -} - -async function runVariant({ replay }) { - const variant = replay ? 'with-replay' : 'no-replay'; - const variantDir = path.join(OUT_DIR, variant); - fs.mkdirSync(variantDir, { recursive: true }); - const fakeScript = path.join(variantDir, 'fake-hermes-acp.mjs'); - const fakeLog = path.join(variantDir, 'fake-hermes-acp.jsonl'); - writeFakeHermesAcp(fakeScript); - const port = await pickPort(); - const baseUrl = `http://127.0.0.1:${port}`; - const runtimes = JSON.stringify([{ name: 'hermes', bin: 'node', args: [fakeScript], env: { MNOTE_TASK560_HERMES_REPLAY: replay ? '1' : '0', MNOTE_TASK560_FAKE_LOG: fakeLog }, title: 'Hermes Fake' }]); - const server = spawn('cargo', ['run', '-q', '-p', 'mnote-web', '--bin', 'mnote-web'], { - cwd: path.join(ROOT, 'rust'), - env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: 'hermes' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let stderr = ''; - server.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - server.stdout.on('data', () => {}); - let browser; - const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-task560-hermes-${variant}-`)); - try { - await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS); - browser = await chromium.launch({ headless: process.env.HEADFUL !== '1', executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - const capturedRuns = []; - page.on('request', (request) => { - if (!request.url().includes('/api/hermes/client/runs') || request.method() !== 'POST') return; - try { capturedRuns.push(JSON.parse(request.postData() || '{}')); } catch { capturedRuns.push({ raw: request.postData() || '' }); } - }); - const viewer = await signIn(context, baseUrl); - const actorId = viewer.userId || ACTOR; - const suffix = `${Date.now().toString(36)}_${variant.replace('-', '_')}`.toUpperCase(); - const workspaceId = `local-ws:${actorId}:task560-${suffix.toLowerCase()}`; - const rootUri = `file://${root}`; - const relativePath = `Task560-${suffix}.md`; - const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`; - fs.mkdirSync(path.join(root, '.mnote'), { recursive: true }); - fs.writeFileSync(path.join(root, '.mnote', 'workspace.json'), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, 'utf8'); - fs.writeFileSync(path.join(root, relativePath), `# Task560 Hermes Load Replay\n\n${suffix}\n`, 'utf8'); - // 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite - await setupWorkspaceAccess(context.request, baseUrl, { - actorId, - email: `${actorId}@example.com`, - username: actorId, - displayName: actorId, - role: "user", - workspaceId, - workspaceName: "Task560 Hermes Load Replay", - rootUri, - rootPath: root, - sourceKind: "local_folder", - permission: "write", - capabilities: ["ai"], - grantSource: "smoke", - grantCreatedBy: actorId, - timeoutMs: TIMEOUT_MS, - }); - const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`); - url.searchParams.set('sourceKind', 'local_folder'); - url.searchParams.set('rootUri', rootUri); - url.searchParams.set('workspaceId', workspaceId); - await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS }); - await page.getByTestId('wolai-floating-ai').click({ timeout: TIMEOUT_MS }); - await selectHermes(page); - - const firstMarker = `TASK560_FIRST_${suffix}`; - const secondMarker = `TASK560_SECOND_${suffix}`; - const first = await sendPrompt(page, `只回复 ${firstMarker},不要解释。`, firstMarker); - const second = await sendPrompt(page, `只回复 ${secondMarker},不要解释。`, secondMarker); - assert.equal(capturedRuns.length, 2, `应捕获两次 Hermes Page AI run,实际 ${capturedRuns.length}`); - assert(capturedRuns.every((body) => body.acpRuntime === 'hermes'), '两次 run 都应走 Hermes ACP'); - assert(capturedRuns[1].acpSessionId, '第二轮请求应携带上一轮 acpSessionId'); - - // 通过 API helper + capturedRuns + fake ACP log 验证持久化和 replay 行为,不再直读 SQLite events - const firstRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: first.runId }); - const secondRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId: second.runId }); - assert(firstRun, `first run ${first.runId} 应 persist`); - assert(secondRun, `second run ${second.runId} 应 persist`); - - const infoCount1 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: first.runId, eventType: 'session.info.updated' }); - const infoCount2 = await countAiRuntimeEvents(context.request, baseUrl, { userId: actorId, runId: second.runId, eventType: 'session.info.updated' }); - assert(Number(infoCount1) >= 1, `first run 应有 session.info.updated`); - assert(Number(infoCount2) >= 1, `second run 应有 session.info.updated`); - - const acpSessionIds = [capturedRuns[0].acpSessionId, capturedRuns[1].acpSessionId].filter(Boolean); - assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(acpSessionIds)}`); - - // adapter_replay 事件的 payload filter(source === 'adapter_replay')需要新的 dev seed kind - // (listAiRuntimeEvents 或 countByPayloadField),当前通过 fake ACP log 验证 session/load 行为 - const fakeLogRows = readFakeLog(fakeLog); - assert(fakeLogRows.some((row) => row.method === 'session/new'), '第一轮应调用 session/new'); - assert(fakeLogRows.some((row) => row.method === 'session/load' && row.sessionId === acpSessionIds[0]), '第二轮应先 session/load stored acpSessionId'); - const screenshotPath = path.join(variantDir, 'task560-hermes-load-replay.png'); - await page.screenshot({ path: screenshotPath, fullPage: true }); - const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], fakeLogRows, capturedRuns, screenshotPath }; - fs.writeFileSync(path.join(variantDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8'); - await context.close().catch(() => undefined); - return result; - } catch (error) { - fs.writeFileSync(path.join(variantDir, 'failure.json'), `${JSON.stringify({ ok: false, variant, error: String(error && error.stack || error), stderr, fakeLog: readFakeLog(fakeLog) }, null, 2)}\n`, 'utf8'); - throw error; - } finally { - if (browser) await browser.close().catch(() => undefined); - server.kill('SIGTERM'); - await new Promise((resolve) => server.once('exit', resolve)); - if (process.env.MNOTE_KEEP_TASK560_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true }); - } -} - -async function main() { - fs.mkdirSync(OUT_DIR, { recursive: true }); - const results = []; - results.push(await runVariant({ replay: false })); - results.push(await runVariant({ replay: true })); - const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, screenshotPath: result.screenshotPath })) }; - fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); - console.log(JSON.stringify(summary, null, 2)); -} - -main().catch((error) => { - console.error(error && error.stack ? error.stack : error); - process.exit(1); -}); diff --git a/scripts/task561-page-ai-session-dashboard-smoke.js b/scripts/task561-page-ai-session-dashboard-smoke.js deleted file mode 100644 index 0418da9b..00000000 --- a/scripts/task561-page-ai-session-dashboard-smoke.js +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const http = require("node:http"); -const net = require("node:net"); -const os = require("node:os"); -const path = require("node:path"); -const { spawn } = require("node:child_process"); -const { chromium } = require("playwright"); - -const ROOT = path.resolve(__dirname, ".."); -const { - setupWorkspaceAccess, - seedAiRuntime, - getAiRuntimeRun, -} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed")); -const OUT_DIR = process.env.MNOTE_PAGE_AI_SESSION_DASHBOARD_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task561-session-dashboard-")); -const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000); -const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; -const ACTOR = "mnote-e2e"; -const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; - -function pickPort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = address && typeof address === "object" ? address.port : 0; - server.close(() => resolve(port)); - }); - server.on("error", reject); - }); -} - -function waitForHttpOk(url, timeoutMs) { - const deadline = Date.now() + timeoutMs; - return new Promise((resolve, reject) => { - const tick = () => { - const request = http.get(url, (response) => { - response.resume(); - if (response.statusCode >= 200 && response.statusCode < 500) return resolve(); - retry(); - }); - request.on("error", retry); - request.setTimeout(1000, () => { request.destroy(); retry(); }); - }; - const retry = () => { - if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`)); - setTimeout(tick, 250); - }; - tick(); - }); -} - -async function signIn(context, baseUrl) { - const response = await context.request.fetch(`${baseUrl}/api/auth`, { - method: "POST", - headers: { "content-type": "application/json" }, - data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } }, - timeout: TIMEOUT_MS, - }); - const text = await response.text(); - assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); - const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS }); - return JSON.parse(await whoami.text()); -} - -async function main() { - fs.mkdirSync(OUT_DIR, { recursive: true }); - const port = await pickPort(); - const baseUrl = `http://127.0.0.1:${port}`; - const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { - cwd: path.join(ROOT, "rust"), - env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}` }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task561-session-dashboard-")); - let browser; - try { - await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS); - browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const viewer = await signIn(context, baseUrl); - const actorId = viewer.userId || ACTOR; - const suffix = Date.now().toString(36); - const workspaceId = `local-ws:${actorId}:task561-${suffix}`; - const sessionId = `mnote_task561_${suffix}`; - const runId = `run_task561_${suffix}`; - const rootUri = `file://${root}`; - const relativePath = `Task561-${suffix}.md`; - const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`; - fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); - fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8"); - fs.writeFileSync(path.join(root, relativePath), `# Task561 Dashboard\n\n${suffix}\n`, "utf8"); - // 通过 API helper 写入 workspace/用户/授权 + run/events,不再直写 SQLite - await setupWorkspaceAccess(context.request, baseUrl, { - actorId, - email: `${actorId}@example.com`, - username: actorId, - displayName: actorId, - role: "user", - workspaceId, - workspaceName: "Task561 Session Dashboard", - rootUri, - rootPath: root, - sourceKind: "local_folder", - permission: "write", - capabilities: ["ai"], - grantSource: "smoke", - grantCreatedBy: actorId, - timeoutMs: TIMEOUT_MS, - }); - await seedAiRuntime(context.request, baseUrl, { - id: `airun_${runId}`, - userId: actorId, - workspaceId, - documentId, - sessionId, - runId, - title: `Task561 Seed ${suffix}`, - profile: "reasonix", - acpRuntime: "reasonix", - traceId: `trace_${suffix}`, - status: "completed", - runtimeJson: { status: "completed", acpSessionId: `acp_${suffix}` }, - payloadJson: { message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: "reasonix", acpRuntime: "reasonix" }, - events: [ - { id: `aievt_${runId}_1`, eventType: "message.delta", payloadJson: { delta: `Task561 assistant answer ${suffix}` } }, - { id: `aievt_${runId}_2`, eventType: "run.completed", payloadJson: { stopReason: "EndTurn" } }, - ], - timeoutMs: TIMEOUT_MS, - }); - const page = await context.newPage(); - const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`); - url.searchParams.set("sourceKind", "local_folder"); - url.searchParams.set("rootUri", rootUri); - url.searchParams.set("workspaceId", workspaceId); - await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); - await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS }); - const settingTabs = page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]'); - const settingTabTexts = await settingTabs.evaluateAll((nodes) => nodes.map((node) => node.textContent.trim()).filter(Boolean)); - assert.deepEqual(settingTabTexts, ['MNote', 'Reasonix', 'Hermes'], `设置页 tabs 应只包含 MNote / Reasonix / Hermes: ${JSON.stringify(settingTabTexts)}`); - assert(!settingTabTexts.includes('Common') && !settingTabTexts.includes('Chat-only') && !settingTabTexts.includes('高级') && !settingTabTexts.includes('Runtime'), `设置页不应显示旧一级 tab: ${JSON.stringify(settingTabTexts)}`); - const mnotePanelText = await page.locator('[data-page-ai-panel="agent"]').textContent({ timeout: TIMEOUT_MS }); - assert(String(mnotePanelText || '').includes('授权区域'), 'MNote tab 应显示授权区域'); - assert(String(mnotePanelText || '').includes('默认上下文'), 'MNote tab 应显示默认上下文'); - assert(String(mnotePanelText || '').includes('MNote 工具'), 'MNote tab 应显示 MNote 工具'); - assert(String(mnotePanelText || '').includes('会话与审计'), 'MNote tab 应显示会话与审计'); - await page.locator('[data-page-ai-panel="agent"] [data-page-ai-tab="reasonix-settings"]').click({ timeout: TIMEOUT_MS }); - const reasonixPanelText = await page.locator('[data-page-ai-panel="reasonix-settings"]').textContent({ timeout: TIMEOUT_MS }); - assert(String(reasonixPanelText || '').includes('ACP runtime'), 'Reasonix tab 应显示 ACP runtime'); - assert(!/MNOTE_WEB_HERMES_UPSTREAM_URL|API key|Hermes 设置入口/.test(String(reasonixPanelText || '')), `Reasonix tab 不应显示 Hermes gateway/API key 错误: ${reasonixPanelText}`); - await page.locator('[data-page-ai-panel="reasonix-settings"] [data-page-ai-tab="hermes-settings"]').click({ timeout: TIMEOUT_MS }); - const hermesPanelText = await page.locator('[data-page-ai-panel="hermes-settings"]').textContent({ timeout: TIMEOUT_MS }); - assert(String(hermesPanelText || '').includes('Hermes profile'), 'Hermes tab 应显示 profile'); - assert(String(hermesPanelText || '').includes('Hermes gateway'), 'Hermes tab 应显示 gateway'); - assert(!String(hermesPanelText || '').includes('native-live queue'), 'Hermes tab 不应显示 Reasonix native-live queue 当前状态'); - const settingsScreenshotPath = path.join(OUT_DIR, "task561-settings-ia.png"); - await page.screenshot({ path: settingsScreenshotPath, fullPage: true }); - await page.locator('[data-page-ai-action="history"]').first().click({ timeout: TIMEOUT_MS }); - const row = page.locator(`[data-page-ai-session-row="${sessionId}"]`).first(); - await row.waitFor({ state: "visible", timeout: TIMEOUT_MS }); - const statusFilter = page.locator('[data-page-ai-session-status-filter]'); - await statusFilter.waitFor({ state: "visible", timeout: TIMEOUT_MS }); - const statusValues = await statusFilter.locator('option').evaluateAll((options) => options.map((option) => option.value)); - assert(statusValues.includes('completed'), `status filter 应包含 completed: ${JSON.stringify(statusValues)}`); - assert.equal(await row.getAttribute('data-page-ai-session-status'), 'completed'); - await statusFilter.selectOption('completed', { timeout: TIMEOUT_MS }); - await row.waitFor({ state: "visible", timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-session-search]').fill(`dashboard seed ${suffix}`, { timeout: TIMEOUT_MS }); - await row.waitFor({ state: "visible", timeout: TIMEOUT_MS }); - const beforeDeleteScreenshotPath = path.join(OUT_DIR, "task561-session-dashboard-before-delete.png"); - await page.screenshot({ path: beforeDeleteScreenshotPath, fullPage: true }); - const exportApiResponse = await context.request.fetch(`${baseUrl}/api/page-ai/sessions/${encodeURIComponent(sessionId)}/export?workspaceId=${encodeURIComponent(workspaceId)}&documentId=${encodeURIComponent(documentId)}&limit=200`, { timeout: TIMEOUT_MS }); - const exportApiText = await exportApiResponse.text(); - assert(exportApiResponse.ok(), `/api/page-ai/sessions export 失败: ${exportApiResponse.status()} ${exportApiText.slice(0, 500)}`); - const exportApiPayload = JSON.parse(exportApiText); - assert.deepEqual(exportApiPayload.export.formats, ['json', 'markdown', 'jsonl']); - assert(String(exportApiPayload.export.markdown || '').includes(sessionId), 'markdown export 应包含 sessionId'); - assert(String(exportApiPayload.export.jsonl || '').includes('"eventType"'), 'jsonl export 应包含事件行'); - await page.locator(`[data-page-ai-session-export="${sessionId}"]`).click({ timeout: TIMEOUT_MS }); - await page.waitForFunction((id) => document.documentElement.getAttribute('data-mnote-page-ai-session-exported') === id, sessionId, { timeout: TIMEOUT_MS }); - await page.evaluate(() => { window.prompt = () => 'Task561 Renamed'; }); - await page.locator(`[data-page-ai-session-rename="${sessionId}"]`).click({ timeout: TIMEOUT_MS }); - await page.waitForFunction((id) => { - const row = document.querySelector(`[data-page-ai-session-row="${id}"]`); - return row && row.textContent.includes('Task561 Renamed'); - }, sessionId, { timeout: TIMEOUT_MS }); - await page.evaluate(() => { window.confirm = () => true; }); - await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: TIMEOUT_MS }); - await page.waitForFunction((id) => !document.querySelector(`[data-page-ai-session-row="${id}"]`), sessionId, { timeout: TIMEOUT_MS }); - // 通过 API helper 验证软删除,不再直读 SQLite - const deletedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId }); - assert(deletedRun, `run ${runId} 应仍 persist(软删除)`); - assert(deletedRun.deletedAt, "delete 应软删除 SQLite session runs"); - const screenshotPath = path.join(OUT_DIR, "task561-session-dashboard.png"); - await page.screenshot({ path: screenshotPath, fullPage: true }); - const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, settingsScreenshotPath, beforeDeleteScreenshotPath, screenshotPath }; - fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); - await context.close().catch(() => undefined); - } catch (error) { - fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, "utf8"); - throw error; - } finally { - if (browser) await browser.close().catch(() => undefined); - server.kill("SIGTERM"); - await new Promise((resolve) => server.once("exit", resolve)); - if (process.env.MNOTE_KEEP_TASK561_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true }); - } -} - -main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); }); diff --git a/scripts/task562-page-ai-reasonix-approval-plan-smoke.js b/scripts/task562-page-ai-reasonix-approval-plan-smoke.js deleted file mode 100644 index 16c4a870..00000000 --- a/scripts/task562-page-ai-reasonix-approval-plan-smoke.js +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const http = require("node:http"); -const net = require("node:net"); -const os = require("node:os"); -const path = require("node:path"); -const { spawn } = require("node:child_process"); -const { chromium } = require("playwright"); - -const ROOT = path.resolve(__dirname, ".."); -const { - setupWorkspaceAccess, - getAiRuntimeRun, -} = require(path.join(ROOT, "scripts", "lib", "control-plane-dev-seed")); -const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-")); -const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000); -const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; -const ACTOR = "mnote-e2e"; -const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; - -function pickPort() { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = address && typeof address === "object" ? address.port : 0; - server.close(() => resolve(port)); - }); - server.on("error", reject); - }); -} - -function waitForHttpOk(url, timeoutMs) { - const deadline = Date.now() + timeoutMs; - return new Promise((resolve, reject) => { - const tick = () => { - const request = http.get(url, (response) => { - response.resume(); - if (response.statusCode >= 200 && response.statusCode < 500) return resolve(); - retry(); - }); - request.on("error", retry); - request.setTimeout(1000, () => { request.destroy(); retry(); }); - }; - const retry = () => { - if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`)); - setTimeout(tick, 250); - }; - tick(); - }); -} - -function writeFakeReasonixAcp(scriptPath) { - fs.writeFileSync(scriptPath, ` -import * as readline from 'node:readline'; -import { appendFileSync } from 'node:fs'; -import { stdin as input, stdout as output } from 'node:process'; -const rl = readline.createInterface({ input, output, terminal: false }); -const LOG = process.env.TASK562_FAKE_LOG || ''; -let nextSessionId = 1; -let pendingPrompt = null; -let permissionRequestId = 7; -let permissionId = 'task562_perm_1'; -let marker = 'TASK562_DONE'; -function log(value) { if (LOG) appendFileSync(LOG, JSON.stringify(value) + '\\n', 'utf8'); } -function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); } -function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); } -rl.on('line', (line) => { - const msg = JSON.parse(line); - log({ dir: 'in', method: msg.method || '', id: msg.id || null, hasResult: Boolean(msg.result), hasError: Boolean(msg.error) }); - if (msg.method === 'initialize') { - send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task562-fake-reasonix', version: '1.0' }, authMethods: [] } }); - return; - } - if (msg.method === 'session/new') { - send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task562_reasonix_session_' + nextSessionId++ } }); - return; - } - if (msg.method === 'session/prompt') { - const sessionId = msg.params?.sessionId || ''; - const prompt = promptText(msg.params?.prompt); - marker = (String(prompt).match(/TASK562_[A-Z0-9_]+/) || ['TASK562_DONE'])[0]; - pendingPrompt = { id: msg.id, sessionId, prompt }; - send({ - jsonrpc: '2.0', - id: permissionRequestId, - method: 'session/request_permission', - params: { - permissionId, - toolName: 'mcp__demo__write_file', - toolCall: { - toolCallId: 'gate-call_1', - title: 'bash', - kind: 'execute', - rawInput: { command: 'echo reasonix permission' } - }, - options: [ - { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, - { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' } - ] - } - }); - log({ dir: 'out', method: 'session/request_permission', id: permissionRequestId, permissionId }); - return; - } - if (msg.id === permissionRequestId && msg.result) { - log({ dir: 'permission-resolved', result: msg.result }); - if (!pendingPrompt) return; - send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: pendingPrompt.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } }); - send({ jsonrpc: '2.0', id: pendingPrompt.id, result: { stopReason: 'end_turn' } }); - pendingPrompt = null; - } -}); -`, "utf8"); -} - -async function signIn(context, baseUrl) { - const response = await context.request.fetch(`${baseUrl}/api/auth`, { - method: "POST", - headers: { "content-type": "application/json" }, - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { account: ACTOR, password: PASSWORD, flow: "signIn" }, - }, - }, - timeout: TIMEOUT_MS, - }); - const text = await response.text(); - assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); - const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS }); - const whoamiText = await whoami.text(); - assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`); - return JSON.parse(whoamiText); -} - -async function selectReasonix(page) { - await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS }); - await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS }); - await page.waitForFunction( - () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix", - null, - { timeout: TIMEOUT_MS }, - ); -} - -async function main() { - fs.mkdirSync(OUT_DIR, { recursive: true }); - const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs"); - const fakeLog = path.join(OUT_DIR, "fake-reasonix-acp.jsonl"); - writeFakeReasonixAcp(fakeScript); - const port = await pickPort(); - const baseUrl = `http://127.0.0.1:${port}`; - const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], env: { TASK562_FAKE_LOG: fakeLog }, title: "Reasonix Fake" }]); - const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { - cwd: path.join(ROOT, "rust"), - env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stderr = ""; - server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task562-reasonix-approval-")); - let browser; - try { - await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS); - browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); - const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); - const page = await context.newPage(); - const viewer = await signIn(context, baseUrl); - const actorId = viewer.userId || ACTOR; - const suffix = Date.now().toString(36).toUpperCase(); - const workspaceId = `local-ws:${actorId}:task562-${suffix.toLowerCase()}`; - const rootUri = `file://${root}`; - const relativePath = `Task562-${suffix}.md`; - const documentId = `local-md:${relativePath.replaceAll("/", "~2F")}`; - fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); - fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8"); - fs.writeFileSync(path.join(root, relativePath), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8"); - // 通过 API helper 写入 workspace/用户/授权,不再直写 SQLite - await setupWorkspaceAccess(context.request, baseUrl, { - actorId, - email: `${actorId}@example.com`, - username: actorId, - displayName: actorId, - role: "user", - workspaceId, - workspaceName: "Task562 Reasonix Approval", - rootUri, - rootPath: root, - sourceKind: "local_folder", - permission: "write", - capabilities: ["ai"], - grantSource: "smoke", - grantCreatedBy: actorId, - timeoutMs: TIMEOUT_MS, - }); - const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`); - url.searchParams.set("sourceKind", "local_folder"); - url.searchParams.set("rootUri", rootUri); - url.searchParams.set("workspaceId", workspaceId); - await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); - await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS }); - await selectReasonix(page); - await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-tab="reasonix-settings"]').first().click({ timeout: TIMEOUT_MS }); - await page.waitForFunction( - () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'reasonix-settings', - null, - { timeout: TIMEOUT_MS }, - ); - - const modelSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.model_id"]'); - const approvalSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.approval_mode"]'); - const planSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.plan_mode"]'); - await modelSelect.selectOption("mimo-pro", { timeout: TIMEOUT_MS }); - await approvalSelect.selectOption("ask", { timeout: TIMEOUT_MS }); - await planSelect.selectOption("auto", { timeout: TIMEOUT_MS }); - await page.waitForFunction( - () => { - const text = document.querySelector('[data-page-ai-model-status]')?.textContent || ''; - return text.includes('Reasonix') && text.includes('mimo-pro') && text.includes('审批:询问') && text.includes('计划:自动'); - }, - null, - { timeout: TIMEOUT_MS }, - ); - await page.evaluate(() => { - const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - const buttons = Array.from(drawer ? drawer.querySelectorAll('[data-page-ai-tab="chat"]') : []); - const button = buttons.find((node) => node instanceof HTMLElement && node.offsetParent !== null); - if (button) button.click(); - }); - await page.waitForFunction( - () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'chat', - null, - { timeout: TIMEOUT_MS }, - ); - await page.waitForSelector('[data-page-ai-reasonix-quick-controls]:not([hidden])', { timeout: TIMEOUT_MS }); - const quickControlsText = await page.locator('[data-page-ai-reasonix-quick-controls]').textContent({ timeout: TIMEOUT_MS }); - assert(String(quickControlsText || '').includes('模型'), `聊天输入区应显示模型选择: ${quickControlsText}`); - assert(String(quickControlsText || '').includes('审批'), `聊天输入区应显示审批选择: ${quickControlsText}`); - assert(String(quickControlsText || '').includes('Plan'), `聊天输入区应显示 Plan 选择: ${quickControlsText}`); - assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.model_id"]').inputValue({ timeout: TIMEOUT_MS }), 'mimo-pro', '模型 quick control 应同步设置值'); - assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.approval_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'ask', '审批 quick control 应同步设置值'); - assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.plan_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'auto', 'Plan quick control 应同步设置值'); - - const marker = `TASK562_DONE_${suffix}`; - await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS }); - await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS }); - await page.waitForFunction( - () => !!document.querySelector('[data-page-ai-permission-dialog]') && !document.querySelector('[data-page-ai-permission-dialog]')?.hidden, - null, - { timeout: TIMEOUT_MS }, - ); - const permissionText = await page.locator('[data-page-ai-permission-dialog]').textContent({ timeout: TIMEOUT_MS }); - assert(String(permissionText || '').includes('bash') && String(permissionText || '').includes('echo reasonix permission'), `权限弹窗应展示 tool 和命令: ${permissionText}`); - const resolveRequestPromise = page.waitForRequest( - (request) => request.url().includes('/resolve-permission') && request.method() === 'POST', - { timeout: 5000 }, - ).catch(() => null); - const resolveResponsePromise = page.waitForResponse( - (response) => response.url().includes('/resolve-permission'), - { timeout: 5000 }, - ).catch(() => null); - await page.evaluate(() => { - const button = document.querySelector('[data-page-ai-permission-dialog] [data-page-ai-permission-action="allow"]'); - if (button instanceof HTMLElement) button.click(); - }); - const resolveRequest = await resolveRequestPromise; - assert(resolveRequest, '点击允许后应发出 resolve-permission 请求'); - const resolveResponse = await resolveResponsePromise; - assert(resolveResponse && resolveResponse.ok(), `resolve-permission 应成功,实际 status=${resolveResponse && resolveResponse.status()} url=${resolveRequest.url()} body=${resolveRequest.postData() || ''}`); - await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS }); - await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS }); - - const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || ''); - assert(runId, '缺少 runId'); - const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS }); - assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker, `AI 回复不符合预期: ${JSON.stringify(text && text.slice(0, 800))}`); - const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS }); - assert(String(runtimeStrip || '').includes('Reasonix'), `runtime strip 应显示 Reasonix: ${runtimeStrip}`); - assert(String(runtimeStrip || '').includes('mimo-pro'), `runtime strip 应显示已选 model: ${runtimeStrip}`); - assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${runtimeStrip}`); - assert(String(runtimeStrip || '').includes('计划:自动'), `runtime strip 应显示计划模式: ${runtimeStrip}`); - - // 通过 API helper 读取 run 持久化状态,不再直读 SQLite - const persistedRun = await getAiRuntimeRun(context.request, baseUrl, { userId: actorId, runId }); - assert(persistedRun, `run ${runId} 应 persist`); - assert.equal(persistedRun.status, 'completed', 'API run status 应为 completed'); - const screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png'); - await page.screenshot({ path: screenshotPath, fullPage: true }); - const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath }; - fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8'); - console.log(JSON.stringify(result, null, 2)); - await context.close().catch(() => undefined); - } catch (error) { - fs.writeFileSync(path.join(OUT_DIR, 'failure.json'), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, 'utf8'); - throw error; - } finally { - if (browser) await browser.close().catch(() => undefined); - server.kill('SIGTERM'); - await new Promise((resolve) => server.once('exit', resolve)); - if (process.env.MNOTE_KEEP_TASK562_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true }); - } -} - -main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); }); diff --git a/scripts/task762-page-ai-board-first-smoke.js b/scripts/task762-page-ai-board-first-smoke.js deleted file mode 100644 index 79be37f6..00000000 --- a/scripts/task762-page-ai-board-first-smoke.js +++ /dev/null @@ -1,433 +0,0 @@ -#!/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, - UI_TIMEOUT_MS, - ensureAuthenticated, -} = require("./tree-shell-smoke-helpers"); - -const TASK = "task762-page-ai-board-first-smoke"; -const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); -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)); - -function fileUrl(localPath) { - return `file://${localPath}`; -} - -function localMdDocumentId(relativePath) { - return `local-md:${relativePath.replaceAll("/", "~2F")}`; -} - -function writeWorkspaceManifest(root, ownerId, workspaceId) { - fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); - fs.writeFileSync( - path.join(root, ".mnote", "workspace.json"), - `${JSON.stringify({ - workspaceId, - ownerId, - createdAt: new Date().toISOString(), - capabilities: ["local_files", "ai_sessions", "markdown_edit"], - }, null, 2)}\n`, - "utf8", - ); -} - -function documentUrl(root, workspaceId, relativePath) { - const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`); - url.searchParams.set("sourceKind", "local_folder"); - url.searchParams.set("rootUri", fileUrl(root)); - url.searchParams.set("workspaceId", workspaceId); - url.searchParams.set("treeView", "filetree"); - return url.toString(); -} - -async function waitForEditorText(page, expected) { - await page.waitForFunction( - (text) => { - const editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror'); - return (editor?.textContent || "").includes(text); - }, - expected, - { timeout: UI_TIMEOUT_MS }, - ); -} - -async function saveScreenshot(page, name) { - const target = path.join(OUTPUT_DIR, `${name}.png`); - await page.screenshot({ path: target, fullPage: true }); - return target; -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const suffix = Date.now().toString(36); - const actorId = "mnote-e2e"; - const workspaceId = `local-ws:${actorId}:task762-${suffix}`; - const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task762-board-ai-")); - const rootUri = fileUrl(root); - const relativePath = "BoardFirstPage.md"; - const documentId = localMdDocumentId(relativePath); - const filePath = path.join(root, relativePath); - const beforeToken = `BOARD_UI_EDIT_BEFORE_${suffix}`; - const afterToken = `BOARD_UI_EDIT_AFTER_${suffix}`; - const capturedBoardRuns = []; - const capturedBoardRunResponses = []; - const consoleErrors = []; - let caughtError = null; - - writeWorkspaceManifest(root, actorId, workspaceId); - fs.writeFileSync( - filePath, - ["# Board First Page AI", "", `页面段落:${beforeToken}`, ""].join("\n"), - "utf8", - ); - - const browser = await chromium.launch({ - headless: process.env.HEADFUL !== "1", - ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), - }); - const context = await browser.newContext({ - viewport: { width: 1440, height: 960 }, - locale: "zh-CN", - extraHTTPHeaders: { - "x-mnote-actor-id": actorId, - "x-mnote-actor-type": "user", - }, - }); - const page = await context.newPage(); - - page.on("console", (message) => { - if (["error", "warning"].includes(message.type())) { - consoleErrors.push({ type: message.type(), text: message.text() }); - } - }); - page.on("request", (request) => { - if (!request.url().includes("/api/page-ai/board/runs") || request.method() !== "POST") return; - let body = null; - try { - body = JSON.parse(request.postData() || "{}"); - } catch { - body = request.postData() || ""; - } - capturedBoardRuns.push({ url: request.url(), method: request.method(), body }); - }); - - await page.route("**/api/page-ai/board/workers", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - schema: "agent_board.page_ai_route.v2", - surface: "mnote-page-ai", - workerPresetId: "mnote-page-ai-zcode", - workerName: "MNote 页面 AI · ZCode", - allowedWorkerPresetIds: ["mnote-page-ai-zcode"], - modelOverride: "zcode-default", - modelOptions: [ - { id: "zcode-default", label: "默认", default: true }, - { id: "zcode-fast", label: "快速" }, - { id: "zcode-strong", label: "强力" }, - ], - workers: [{ - id: "mnote-page-ai-zcode", - name: "MNote 页面 AI · ZCode", - surface: "mnote-page-ai", - agentType: "zcode", - modelOptions: [ - { id: "zcode-default", label: "默认", default: true }, - { id: "zcode-fast", label: "快速" }, - { id: "zcode-strong", label: "强力" }, - ], - }, { - id: "qa-browser-worker", - name: "QA Browser Worker", - surface: "agent-board-console", - role: "qa", - }], - }), - }); - }); - - await page.route("**/api/page-ai/board/workflows", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - schema: "agent_board.page_ai_route.v2", - surface: "mnote-page-ai", - workflowId: "builtin-mnote-page-ai-chat", - workflowName: "MNote 页面 AI", - allowedWorkflowIds: ["builtin-mnote-page-ai-chat"], - workflows: [{ - id: "builtin-mnote-page-ai-chat", - name: "MNote 页面 AI", - surface: "mnote-page-ai", - }, { - id: "general-hotfix-workflow", - name: "通用 hotfix workflow", - surface: "agent-board-console", - }], - }), - }); - }); - - await page.route("**/api/page-ai/board/runs", async (route) => { - if (route.request().method() !== "POST") return route.continue(); - const body = JSON.parse(route.request().postData() || "{}"); - capturedBoardRunResponses.push({ kind: "create", body }); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - schema: "mnote.page_ai_board_run.v1", - surface: "mnote-page-ai", - runId: `board-run-${suffix}`, - workflowId: body.workflowId, - workerPresetId: body.workerPresetId, - modelOverride: body.modelOverride, - board: { run: { id: `board-run-${suffix}`, status: "running" } }, - }), - }); - }); - - await page.route(`**/api/page-ai/board/runs/board-run-${suffix}`, async (route) => { - capturedBoardRunResponses.push({ kind: "get" }); - fs.writeFileSync(filePath, ["# Board First Page AI", "", `页面段落:${afterToken}`, ""].join("\n"), "utf8"); - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - schema: "mnote.page_ai_board_run_status.v1", - runId: `board-run-${suffix}`, - board: { - run: { id: `board-run-${suffix}`, status: "complete" }, - events: [ - { type: "workflow.started", message: "run started", createdAt: new Date().toISOString() }, - { type: "worker.completed", message: "file edited", createdAt: new Date().toISOString() }, - ], - }, - receipt: { - schema: "agent_board.workflow_run_receipt.v2", - runId: `board-run-${suffix}`, - surface: "mnote-page-ai", - workflowId: "builtin-mnote-page-ai-chat", - workerPresetId: "mnote-page-ai-zcode", - modelOverride: "zcode-fast", - finalAnswer: `已把当前页面中的 ${beforeToken} 替换为 ${afterToken}。`, - changedFiles: [{ path: filePath, status: "modified" }], - verification: [{ command: "read file", status: "passed", output: afterToken }], - remaining: [], - }, - }), - }); - }); - - try { - await page.route("**/api/user/access-policy**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ok: true, - controlPlane: "sqlite", - grants: [{ - id: `grant_task762_${suffix}`, - userId: actorId, - workspaceId, - rootUri, - rootPath: root, - permission: "write", - recursive: true, - capabilities: ["ai", "markdown_edit"], - source: "smoke", - status: "active", - }], - }), - }); - }); - await page.route("**/api/ui/preferences**", async (route) => { - await route.fulfill({ - status: 200, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, owner: "mnote-web", result: {} }), - }); - }); - - await ensureAuthenticated(page, context.request); - const response = await page.goto(documentUrl(root, workspaceId, relativePath), { - waitUntil: "domcontentloaded", - timeout: UI_TIMEOUT_MS, - }); - assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`); - await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({ - state: "visible", - timeout: UI_TIMEOUT_MS, - }); - await waitForEditorText(page, beforeToken); - await page.evaluate(() => localStorage.removeItem("mnote.page_ai.legacy_provider_mode")); - - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - () => { - const drawerText = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || ""; - return drawerText.includes("MNote 页面 AI · ZCode") && !drawerText.includes("QA Browser Worker") && !drawerText.includes("通用 hotfix workflow"); - }, - null, - { timeout: UI_TIMEOUT_MS }, - ); - const modelSelect = page.locator("[data-page-ai-board-model]"); - await modelSelect.selectOption("zcode-fast", { timeout: UI_TIMEOUT_MS }); - await page.keyboard.press("Escape").catch(() => undefined); - await page.locator("[data-page-ai-input]").fill( - `请编辑当前页面真实 Markdown 文件,把 ${beforeToken} 替换为 ${afterToken}。完成后说明 MNote capability 已加载。`, - { timeout: UI_TIMEOUT_MS }, - ); - await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); - - await page.waitForFunction( - (expected) => { - const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || ""; - return text.includes("正在处理") || text.includes(expected); - }, - afterToken, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForFunction( - () => { - const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - const subtitle = drawer?.querySelector('.wolai-page-ai-subtitle')?.textContent || ""; - const agentChip = drawer?.querySelector('[data-page-ai-agent-chip]')?.textContent || ""; - const hiddenLegacyTabs = Array.from(drawer?.querySelectorAll('[data-page-ai-tab="agent"], [data-page-ai-tab="reasonix-settings"], [data-page-ai-tab="hermes-settings"]') || []) - .every((node) => node instanceof HTMLElement && node.hidden === true); - return subtitle.includes("Agent Board") && agentChip.includes("MNote 页面 AI") && hiddenLegacyTabs; - }, - null, - { timeout: UI_TIMEOUT_MS }, - ); - await page.waitForFunction( - () => document.documentElement.getAttribute("data-mnote-page-ai-run-status") === "completed", - null, - { timeout: Math.max(UI_TIMEOUT_MS, 180_000) }, - ); - await page.waitForFunction( - (expected) => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes(expected), - afterToken, - { timeout: UI_TIMEOUT_MS }, - ); - - const finalDiskContent = fs.readFileSync(filePath, "utf8"); - assert(finalDiskContent.includes(afterToken), "磁盘文件应包含 Board worker 写入后的标记"); - assert(!finalDiskContent.includes(beforeToken), "磁盘文件不应再包含旧标记"); - await waitForEditorText(page, afterToken); - assert.strictEqual(capturedBoardRuns.length, 1, `应只创建一个 Board Page AI run,实际 ${capturedBoardRuns.length}`); - const runBody = capturedBoardRuns[0].body; - assert.strictEqual(runBody.workflowId, "builtin-mnote-page-ai-chat", "Page AI 应默认走 MNote 专用 Board workflow"); - assert.strictEqual(runBody.workerPresetId, "mnote-page-ai-zcode", "Page AI 应默认走 MNote 专用 ZCode worker"); - assert.strictEqual(runBody.modelOverride, "zcode-fast", "Page AI 应传递 worker 内模型档位"); - assert.strictEqual(runBody.envelope?.schema, "mnote.page_ai.board_task.v1", "run payload 应携带 Board envelope"); - assert.strictEqual(runBody.envelope?.modelOverride, "zcode-fast", "envelope 应记录模型档位"); - assert.strictEqual(runBody.envelope?.primaryTarget?.absolutePath, filePath, "primaryTarget 应指向当前真实 Markdown 文件"); - assert(Array.isArray(runBody.envelope?.capabilities), "envelope 应携带 MNote capabilities"); - assert(runBody.envelope.capabilities.includes("mnote.current_page.read"), "capabilities 应包含当前页读取能力"); - assert(runBody.envelope.capabilities.includes("mnote.local_file.receipt"), "capabilities 应包含本地文件收据能力"); - - const drawerText = await page.locator('[data-testid="wolai-page-ai-drawer"]').textContent({ timeout: UI_TIMEOUT_MS }); - assert(!drawerText.includes("Agent Board run 已创建"), "默认聊天面不应把 Board run 创建日志作为 assistant 气泡显示"); - assert(drawerText.includes(`已把当前页面中的 ${beforeToken} 替换为 ${afterToken}。`), "主聊天应展示 receipt.finalAnswer 自然回复"); - assert(!drawerText.includes("## Completed"), "主聊天不应展示 Board Completed 报告标题"); - await page.locator(`[data-page-ai-board-run-detail="board-run-${suffix}"]`).click({ timeout: UI_TIMEOUT_MS }); - await page.waitForFunction( - (expectedPath) => { - const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || ""; - return text.includes("Run detail") && text.includes("changed files") && text.includes(expectedPath) && text.includes("timeline"); - }, - filePath, - { timeout: UI_TIMEOUT_MS }, - ); - await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); - const restoredStorage = await page.evaluate(() => { - const keys = Object.keys(window.localStorage).filter((key) => key.startsWith("hermes_page_ai_session:")); - return JSON.stringify(keys.map((key) => ({ key, value: window.localStorage.getItem(key) || "" }))); - }); - if (!restoredStorage.includes(afterToken)) { - throw new assert.AssertionError({ - message: `刷新后 localStorage 应保留 Board-first session/history: ${restoredStorage.slice(0, 1000)}`, - }); - } - await page.waitForFunction( - (expected) => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes(expected), - afterToken, - { timeout: UI_TIMEOUT_MS }, - ); - const screenshot = await saveScreenshot(page, "01-board-first-edit"); - const result = { - ok: true, - task: TASK, - baseUrl: BASE_URL, - root, - rootUri, - workspaceId, - documentId, - relativePath, - filePath, - beforeToken, - afterToken, - screenshot, - capturedBoardRuns, - capturedBoardRunResponses, - drawerText, - consoleErrors, - finalDiskContent, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); - } catch (error) { - caughtError = error; - await saveScreenshot(page, "failure").catch(() => undefined); - fs.writeFileSync( - path.join(OUTPUT_DIR, "failure.json"), - `${JSON.stringify({ - ok: false, - error: error instanceof Error ? error.stack || error.message : String(error), - capturedBoardRuns, - consoleErrors, - root, - rootUri, - filePath, - diskContent: fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "", - }, null, 2)}\n`, - "utf8", - ); - } finally { - await page.close().catch(() => undefined); - await context.close().catch(() => undefined); - await browser.close().catch(() => undefined); - } - - if (caughtError) { - throw caughtError; - } -} - -if (require.main === module) { - main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); - }); -} diff --git a/scripts/task769-weknora-knowledge-settings-ui-static-smoke.js b/scripts/task769-weknora-knowledge-settings-ui-static-smoke.js deleted file mode 100644 index 548e0fd3..00000000 --- a/scripts/task769-weknora-knowledge-settings-ui-static-smoke.js +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); - -const ROOT = process.cwd(); -const OUTPUT_DIR = path.join(ROOT, "tmp", "task769-weknora-knowledge-settings-ui-static-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const UI_PATH = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js"; - -function read(relativePath) { - return fs.readFileSync(path.join(ROOT, relativePath), "utf8"); -} - -function assertContains(content, needle, label) { - assert(content.includes(needle), `${label} 缺少: ${needle}`); -} - -function assertNotContains(content, needle, label) { - assert(!content.includes(needle), `${label} 不应包含: ${needle}`); -} - -function assertOrdered(content, first, second, label) { - const firstIndex = content.indexOf(first); - const secondIndex = content.indexOf(second); - assert(firstIndex >= 0, `${label} 缺少: ${first}`); - assert(secondIndex >= 0, `${label} 缺少: ${second}`); - assert(firstIndex < secondIndex, `${label} 顺序错误: ${first} 应优先于 ${second}`); -} - -function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const ui = read(UI_PATH); - - for (const forbidden of [ - "打开 LightRAG", - "相似度阈值", - "相关度:", - "本地 (Local)", - "全局 (Global)", - "朴素 (Naive)", - "混合模式 (Mix)", - "正在读取 LightRAG 状态", - "无法连接 LightRAG 服务", - ]) { - assertNotContains(ui, forbidden, "WeKnora knowledge settings UI"); - } - - for (const required of [ - "WeKnora", - "WeKnora hybrid", - "data-testid=\"mnote-weknora-knowledge-settings-panel\"", - "data-knowledge-rag-default-provider=\"weknora\"", - "data-knowledge-rag-legacy-fallback-provider=\"lightrag\"", - "data-knowledge-rag-source-registry=\"mnote\"", - "data-knowledge-rag-provider-index=\"weknora-kb-chunk-index\"", - "data-knowledge-rag-active-provider", - "data-knowledge-rag-reference-boundary", - "data-knowledge-rag-provider=\"weknora\"", - "data-knowledge-rag-provider-id", - "data-knowledge-rag-provider-ids", - "data-knowledge-rag-knowledge-id", - "data-knowledge-rag-chunk-count", - "data-knowledge-rag-processing-status", - "data-knowledge-rag-open-reference-mode", - "data-knowledge-rag-delete-local-file-policy=\"preserve-local-file\"", - "data-kb-rag-score-label=\"provider-ranking-score\"", - "data-kb-rag-score-is-similarity-percent=\"false\"", - "provider 排序分", - "MNote source registry", - "WeKnora KB chunk index", - "provider=weknora", - "provider ids", - "knowledgeId", - "chunkCount", - "processingStatus", - "定位降级", - "删除 provider index 与 registry 映射,不删除本地文件", - "打开知识库", - "Provider 检索策略", - "兼容阈值字段保留在请求层,不作为默认主控", - ]) { - assertContains(ui, required, "WeKnora knowledge settings UI"); - } - - assertOrdered( - ui, - "(weknora && weknora.dashboardUrl)", - "status && (status.dashboardUrl || status.endpoint)", - "dashboardUrl 优先级" - ); - - const result = { - ok: true, - checkedFiles: [UI_PATH], - assertions: { - noDefaultLightRagCopy: true, - noSimilarityThresholdPrimaryControl: true, - noRelevancePercentCopy: true, - weknoraHybridSearchCopy: true, - providerSelectorCoverage: true, - sourceRegistrySelectorCoverage: true, - processingSelectorCoverage: true, - citationOpenReferenceSelectorCoverage: true, - providerRankingScoreCopy: true, - sourceRegistryBoundaryCopy: true, - deleteIndexDoesNotDeleteLocalFileCopy: true, - dashboardUrlPriority: true, - }, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); -} - -main(); diff --git a/scripts/task772-weknora-ingest-search-open-reference-e2e.js b/scripts/task772-weknora-ingest-search-open-reference-e2e.js deleted file mode 100644 index 174fe308..00000000 --- a/scripts/task772-weknora-ingest-search-open-reference-e2e.js +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); -const { request } = require("playwright"); - -const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; -const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`; -const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space"; -const FIXTURE_DIR = process.env.MNOTE_TASK772_FIXTURE_DIR || "knowledge-rag-fixtures-7-68"; -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const SKIP_PATH = path.join(OUTPUT_DIR, "skip.json"); -const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json"); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); -const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK772_WEKNORA_TIMEOUT_MS || 240_000); -const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK772_WEKNORA_POLL_MS || 5_000); - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function envList(name) { - return String(process.env[name] || "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); -} - -function configuredKnowledgeBaseIds() { - return envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS").concat(envList("MNOTE_WEKNORA_KNOWLEDGE_BASE_ID")); -} - -function hasWeKnoraApiKey() { - return Boolean(String(process.env.MNOTE_WEKNORA_API_KEY || process.env.WEKNORA_API_KEY || "").trim()); -} - -function writeJson(filePath, payload) { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); -} - -function skip(reason, details = {}) { - const payload = { - ok: false, - skipped: true, - reason, - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - ...details, - }; - writeJson(SKIP_PATH, payload); - console.log(JSON.stringify(payload, null, 2)); -} - -function failLayer(layer, message, details = {}) { - const error = new Error(message); - error.layer = layer; - error.details = details; - return error; -} - -async function apiJson(context, method, url, data) { - const response = await context.fetch(url, { - method, - data, - headers: { - "x-mnote-actor-id": "mnote-e2e", - "x-mnote-actor-type": "user", - "x-mnote-workspace-id": WORKSPACE_ID, - accept: "application/json", - }, - timeout: UI_TIMEOUT_MS, - }); - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch (_) { - payload = { rawText: text }; - } - return { ok: response.ok(), status: response.status(), payload, text }; -} - -async function signIn(context) { - const response = await context.post(`${BASE_URL}/api/auth`, { - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { - email: "mnote.e2e@example.com", - password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", - flow: "signIn", - }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`); -} - -async function status(context) { - const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); - const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); - if (!result.ok) { - throw failLayer("mnote_status", `knowledge-rag status 失败: ${result.status}`, { - response: result.payload || result.text, - }); - } - return result.payload; -} - -function assertWeKnoraStatus(statusPayload, expectedKbIds) { - const activeProvider = statusPayload?.providerConfig?.active || statusPayload?.provider; - assert.equal(activeProvider, "weknora", `MNote knowledge provider 不是 weknora: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`); - const serverKbIds = statusPayload?.providerConfig?.weknora?.knowledgeBaseIds || []; - assert(serverKbIds.length > 0, `MNote 3000 未加载 WeKnora KB id: ${JSON.stringify(statusPayload?.providerConfig || statusPayload, null, 2)}`); - for (const kbId of expectedKbIds) { - assert(serverKbIds.includes(kbId), `MNote 3000 未加载当前 WeKnora KB id ${kbId}: ${JSON.stringify(serverKbIds)}`); - } -} - -async function ingest(context, sourcePath) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - force: true, - sources: [{ sourcePath }], - }); - if (!result.ok) { - throw failLayer("mnote_ingest_http", `knowledge-rag ingest HTTP 失败: ${result.status}`, { - response: result.payload || result.text, - }); - } - const payload = result.payload; - assert.equal(payload?.provider, "weknora", `ingest provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`); - if (payload?.ok !== true || payload?.retryRequired) { - throw failLayer("weknora_ingest_provider", "WeKnora ingest 未成功完成 provider upload", { - response: payload, - }); - } - const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : []; - const item = configured.find((source) => source.sourceRootRelativePath === sourcePath); - assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(configured, null, 2).slice(0, 3000)}`); - assert.equal(item.provider, "weknora", `ingest source provider 应为 weknora: ${JSON.stringify(item, null, 2)}`); - assert.equal(item.mappingStatus, "provider_mapped", `ingest 应写回 provider_mapped: ${JSON.stringify(item, null, 2)}`); - assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`); - assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`); - assert(item.upload?.attempted === true, `ingest 应真实调用 WeKnora upload: ${JSON.stringify(item, null, 2)}`); - return { payload, item }; -} - -async function search(context, query, sourcePath) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - query, - mode: "hybrid", - topK: 10, - chunkTopK: 10, - includeChunkContent: true, - sourcePaths: [sourcePath], - }); - if (!result.ok) { - throw failLayer("mnote_search_http", `knowledge-rag search HTTP 失败: ${result.status}`, { - response: result.payload || result.text, - }); - } - return result.payload; -} - -function referenceProviderIds(reference) { - return { - knowledgeBaseId: reference?.providerIds?.knowledgeBaseId || reference?.providerKnowledgeBaseId || "", - knowledgeId: reference?.providerIds?.knowledgeId || reference?.providerKnowledgeId || "", - chunkId: reference?.providerIds?.chunkId || reference?.providerChunkId || reference?.chunkId || "", - }; -} - -function findTargetReference(searchPayload, ingestItem, marker, sourcePath) { - const references = Array.isArray(searchPayload?.references) ? searchPayload.references : []; - return references.find((reference) => { - const ids = referenceProviderIds(reference); - const quote = `${reference.displayQuote || ""}\n${reference.rawQuote || ""}\n${reference.quote || ""}`; - return reference.provider === "weknora" - && ids.knowledgeId === ingestItem.providerKnowledgeId - && (reference.sourceRootRelativePath === sourcePath || quote.includes(marker)); - }) || null; -} - -async function waitForSearchHit(context, marker, sourcePath, ingestItem) { - const startedAt = Date.now(); - let lastPayload = null; - let lastError = null; - while (Date.now() - startedAt < POLL_TIMEOUT_MS) { - try { - lastPayload = await search(context, marker, sourcePath); - const reference = findTargetReference(lastPayload, ingestItem, marker, sourcePath); - if (reference) return { payload: lastPayload, reference }; - } catch (error) { - lastError = { - layer: error.layer || "search_poll", - message: error.message, - details: error.details, - }; - } - await sleep(POLL_INTERVAL_MS); - } - throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回目标 chunk 超时", { - marker, - sourcePath, - providerKnowledgeId: ingestItem.providerKnowledgeId, - lastError, - lastPayload, - }); -} - -function rawWeKnoraChunkFromReference(reference) { - const raw = reference?.reference; - assert(raw && typeof raw === "object" && !Array.isArray(raw), `search reference 缺少原始 WeKnora chunk: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); - return { provider: "weknora", ...raw }; -} - -function assertMappedReference(reference, ingestItem, sourcePath, label) { - assert.equal(reference?.provider, "weknora", `${label} provider 应为 weknora: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); - const ids = referenceProviderIds(reference); - assert(ids.knowledgeBaseId, `${label} 缺少 provider knowledgeBaseId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); - assert(ids.knowledgeId, `${label} 缺少 provider knowledgeId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); - assert(ids.chunkId, `${label} 缺少 provider chunkId: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); - assert.equal(ids.knowledgeId, ingestItem.providerKnowledgeId, `${label} provider knowledgeId 未保留 ingest 映射: ${JSON.stringify(ids)}`); - assert.equal(reference.filePath ?? null, null, `${label} 不应把 WeKnora provider filename 伪造成 filePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); - assert.equal(reference.sourceRootRelativePath, sourcePath, `${label} 应通过 registry 映射回 sourceRootRelativePath: ${JSON.stringify(reference, null, 2).slice(0, 3000)}`); - assert.equal(reference.citationDiagnostics?.providerFilenameIsNotLocalPath, true, `${label} 应声明 provider filename 不是本地路径真相: ${JSON.stringify(reference.citationDiagnostics, null, 2)}`); - assert.equal(reference.openAction?.params?.path, sourcePath, `${label} openAction 应使用 registry path,不使用 provider filename: ${JSON.stringify(reference.openAction, null, 2)}`); -} - -async function openReference(context, rawChunk) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/open-reference`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - reference: rawChunk, - }); - if (!result.ok) { - throw failLayer("mnote_open_reference_http", `knowledge-rag open-reference HTTP 失败: ${result.status}`, { - response: result.payload || result.text, - }); - } - const payload = result.payload; - assert.equal(payload?.ok, true, `open-reference ok 应为 true: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`); - assert.equal(payload?.provider, "weknora", `open-reference provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 3000)}`); - return payload; -} - -async function main() { - const expectedKbIds = configuredKnowledgeBaseIds(); - if (expectedKbIds.length === 0 || !hasWeKnoraApiKey()) { - console.warn(JSON.stringify({ - warning: "local_weknora_env_not_visible", - message: "当前 smoke shell 未看到 WeKnora env;继续以 MNote 3000 status 和真实 API 调用为准。", - requiredAny: { - knowledgeBaseId: ["MNOTE_WEKNORA_KNOWLEDGE_BASE_ID", "MNOTE_WEKNORA_KNOWLEDGE_BASE_IDS"], - apiKey: ["MNOTE_WEKNORA_API_KEY", "WEKNORA_API_KEY"], - }, - hasKnowledgeBaseId: expectedKbIds.length > 0, - hasApiKey: hasWeKnoraApiKey(), - }, null, 2)); - } - - if (!fs.existsSync(ROOT_PATH)) { - throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`); - } - - const context = await request.newContext({ baseURL: BASE_URL }); - try { - await signIn(context); - const statusPayload = await status(context); - try { - assertWeKnoraStatus(statusPayload, expectedKbIds); - } catch (error) { - skip("mnote_weknora_provider_or_kb_not_configured", { - assertion: error.message, - providerConfig: statusPayload?.providerConfig || null, - health: statusPayload?.health || null, - }); - return; - } - - const startedAt = Date.now(); - const marker = `task772 weknora e2e marker ${startedAt}`; - const sourceName = `task772-weknora-e2e-${startedAt}.md`; - const sourcePath = `${FIXTURE_DIR}/${sourceName}`; - const absoluteSourcePath = path.join(ROOT_PATH, sourcePath); - fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true }); - fs.writeFileSync( - absoluteSourcePath, - [ - "# Task 772 WeKnora E2E", - "", - marker, - "", - "This markdown file is created by the MNote task772 smoke to verify real WeKnora ingest, search, and open-reference mapping.", - "The provider filename must not become a forged local file path; MNote should map back through its source registry.", - "", - ].join("\n"), - "utf8", - ); - - const ingestResult = await ingest(context, sourcePath); - const searchResult = await waitForSearchHit(context, marker, sourcePath, ingestResult.item); - assert.equal(searchResult.payload?.provider, "weknora", `search provider 应为 weknora: ${JSON.stringify(searchResult.payload, null, 2).slice(0, 3000)}`); - assertMappedReference(searchResult.reference, ingestResult.item, sourcePath, "search reference"); - - const rawChunk = rawWeKnoraChunkFromReference(searchResult.reference); - const openPayload = await openReference(context, rawChunk); - assertMappedReference(openPayload.reference, ingestResult.item, sourcePath, "open-reference"); - - const result = { - ok: true, - skipped: false, - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - sourcePath, - absoluteSourcePath, - marker, - provider: "weknora", - providerKnowledgeBaseId: ingestResult.item.providerKnowledgeBaseId, - providerKnowledgeId: ingestResult.item.providerKnowledgeId, - search: { - resultCount: Array.isArray(searchResult.payload.results) ? searchResult.payload.results.length : 0, - referenceCount: Array.isArray(searchResult.payload.references) ? searchResult.payload.references.length : 0, - providerIds: referenceProviderIds(searchResult.reference), - citationUrl: searchResult.reference.citationUrl || null, - filePath: searchResult.reference.filePath ?? null, - sourceRootRelativePath: searchResult.reference.sourceRootRelativePath || null, - }, - openReference: { - providerIds: referenceProviderIds(openPayload.reference), - citationUrl: openPayload.reference?.citationUrl || null, - filePath: openPayload.reference?.filePath ?? null, - sourceRootRelativePath: openPayload.reference?.sourceRootRelativePath || null, - openAction: openPayload.reference?.openAction || null, - }, - }; - writeJson(RESULT_PATH, result); - console.log(JSON.stringify(result, null, 2)); - } finally { - await context.dispose().catch(() => undefined); - } -} - -main().catch((error) => { - const payload = { - ok: false, - skipped: false, - layer: error.layer || "unexpected", - error: error.stack || error.message || String(error), - details: error.details || null, - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - }; - writeJson(FAILURE_PATH, payload); - console.error(JSON.stringify(payload, null, 2)); - process.exit(1); -}); diff --git a/scripts/task777-weknora-section-context-smoke.js b/scripts/task777-weknora-section-context-smoke.js deleted file mode 100644 index 9bf7b82c..00000000 --- a/scripts/task777-weknora-section-context-smoke.js +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); -const { request } = require("playwright"); - -const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; -const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`; -const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space"; -const TASK772_RESULT_PATH = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e", "result.json"); -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "7-68-runtime"); -const RESULT_PATH = path.join(OUTPUT_DIR, "weknora-section-context-smoke-result.json"); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); - -function readTask772Result() { - if (!fs.existsSync(TASK772_RESULT_PATH)) { - throw new Error(`缺少 task772 结果,请先运行 node scripts/task772-weknora-ingest-search-open-reference-e2e.js: ${TASK772_RESULT_PATH}`); - } - const payload = JSON.parse(fs.readFileSync(TASK772_RESULT_PATH, "utf8")); - assert.equal(payload.ok, true, `task772 结果不是 ok=true: ${JSON.stringify(payload, null, 2).slice(0, 1000)}`); - const sourcePath = payload.sourcePath; - const providerKnowledgeId = payload.providerKnowledgeId; - const providerKnowledgeBaseId = payload.providerKnowledgeBaseId; - const providerChunkId = payload.search?.providerIds?.chunkId || payload.openReference?.providerIds?.chunkId; - assert(sourcePath, "task772 结果缺少 sourcePath"); - assert(providerKnowledgeId, "task772 结果缺少 providerKnowledgeId"); - assert(providerChunkId, "task772 结果缺少 provider chunkId"); - return { - sourcePath, - providerKnowledgeId, - providerKnowledgeBaseId, - providerChunkId, - marker: payload.marker, - }; -} - -async function signIn(context) { - const response = await context.post(`${BASE_URL}/api/auth`, { - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { - account: "mnote-e2e", - password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", - flow: "signIn", - }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`); -} - -async function sectionContext(context, body) { - const response = await context.post(`${BASE_URL}/api/knowledge-rag/section-context`, { - data: { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - maxChars: 4000, - ...body, - }, - headers: { - "x-mnote-actor-id": "mnote-e2e", - "x-mnote-actor-type": "user", - "x-mnote-workspace-id": WORKSPACE_ID, - accept: "application/json", - }, - timeout: UI_TIMEOUT_MS, - }); - const text = await response.text(); - let payload = null; - try { - payload = JSON.parse(text); - } catch { - payload = { rawText: text }; - } - assert(response.ok(), `section-context HTTP ${response.status()}: ${text.slice(0, 1000)}`); - return payload; -} - -function assertProviderChunkPayload(payload, expected) { - assert.equal(payload.provider, "weknora", `provider 应为 weknora: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`); - assert.equal(payload.sidecarRead, false, "WeKnora section-context 不应读取 LightRAG sidecar"); - assert.equal(payload.providerChunkFetch?.attempted, true, "应尝试 WeKnora provider chunk fetch"); - assert.equal(payload.providerChunkFetch?.ok, true, `provider chunk fetch 应成功: ${JSON.stringify(payload.providerChunkFetch, null, 2)}`); - assert(Array.isArray(payload.blocks) && payload.blocks.length > 0, "section-context 应返回 provider chunk blocks"); - assert(Array.isArray(payload.chunks) && payload.chunks.length > 0, "section-context 应返回 agent chunks"); - assert(String(payload.text || "").includes(expected.marker), "section-context text 应包含 task772 marker"); - const first = payload.blocks[0]; - assert(first.providerChunk, `block 应保留 providerChunk metadata: ${JSON.stringify(first, null, 2).slice(0, 1000)}`); - assert.equal(first.providerChunk.knowledgeId, expected.providerKnowledgeId, "providerChunk knowledgeId 应保留"); -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const expected = readTask772Result(); - const context = await request.newContext({ baseURL: BASE_URL }); - let result = { ok: false, task: "task777-weknora-section-context-smoke", expected }; - try { - await signIn(context); - const byChunk = await sectionContext(context, { - sourcePath: expected.sourcePath, - providerKnowledgeId: expected.providerKnowledgeId, - providerKnowledgeBaseId: expected.providerKnowledgeBaseId, - providerChunkId: expected.providerChunkId, - }); - assertProviderChunkPayload(byChunk, expected); - const byKnowledge = await sectionContext(context, { - sourcePath: expected.sourcePath, - providerKnowledgeId: expected.providerKnowledgeId, - providerKnowledgeBaseId: expected.providerKnowledgeBaseId, - }); - assertProviderChunkPayload(byKnowledge, expected); - result = { - ...result, - ok: true, - byChunk: { - providerChunkFetch: byChunk.providerChunkFetch, - providerChunkId: byChunk.providerChunkId, - returnedBlocks: byChunk.blocks.length, - returnedChunks: byChunk.chunks.length, - degradedReason: byChunk.degradedReason, - }, - byKnowledge: { - providerChunkFetch: byKnowledge.providerChunkFetch, - returnedBlocks: byKnowledge.blocks.length, - returnedChunks: byKnowledge.chunks.length, - degradedReason: byKnowledge.degradedReason, - }, - }; - } catch (error) { - result = { - ...result, - ok: false, - error: error instanceof Error ? error.stack || error.message : String(error), - }; - } finally { - await context.dispose().catch(() => undefined); - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - } - console.log(JSON.stringify(result, null, 2)); - if (!result.ok) process.exit(1); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.stack || error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/task781-knowledge-bases-registry-smoke.js b/scripts/task781-knowledge-bases-registry-smoke.js deleted file mode 100644 index 2ad931bc..00000000 --- a/scripts/task781-knowledge-bases-registry-smoke.js +++ /dev/null @@ -1,256 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); -const { request } = require("playwright"); - -const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; -const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`; -const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space"; -const FIXTURE_DIR = process.env.MNOTE_TASK781_FIXTURE_DIR || "knowledge-rag-fixtures-7-68"; -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task781-knowledge-bases-registry-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json"); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); -const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK781_WEKNORA_TIMEOUT_MS || 240_000); -const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK781_WEKNORA_POLL_MS || 5_000); - -function writeJson(filePath, payload) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function failLayer(layer, message, details = {}) { - const error = new Error(message); - error.layer = layer; - error.details = details; - return error; -} - -async function signIn(context) { - const response = await context.post(`${BASE_URL}/api/auth`, { - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { - email: "mnote.e2e@example.com", - password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", - flow: "signIn", - }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`); -} - -async function apiJson(context, method, url, data) { - const response = await context.fetch(url, { - method, - data, - headers: { - "x-mnote-actor-id": "mnote-e2e", - "x-mnote-actor-type": "user", - "x-mnote-workspace-id": WORKSPACE_ID, - accept: "application/json", - }, - timeout: UI_TIMEOUT_MS, - }); - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = { rawText: text }; - } - return { ok: response.ok(), status: response.status(), payload, text }; -} - -async function status(context) { - const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); - const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); - assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`); - return result.payload; -} - -function assertKnowledgeBases(payload) { - assert.equal(payload?.provider, "weknora", `status provider 应为 weknora: ${JSON.stringify(payload?.providerConfig || payload, null, 2).slice(0, 1000)}`); - assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "status 应返回 mnote_knowledge_bases registry schema"); - assert.equal(payload?.registry?.schema, "mnote.knowledge_rag.source_registry.v1", "status 应保留旧 source registry schema"); - assert.equal(payload?.providerConfig?.weknora?.knowledgeBaseRegistry, "mnote_knowledge_bases", "providerConfig 应暴露 KB registry 边界"); - assert.equal(payload?.providerConfig?.weknora?.sourceRegistry, "mnote_knowledge_sources", "providerConfig 应暴露 source registry 边界"); - const bases = payload?.knowledgeBases?.bases || []; - assert(Array.isArray(bases) && bases.length > 0, `knowledgeBases.bases 应至少包含默认 WeKnora KB: ${JSON.stringify(payload?.knowledgeBases, null, 2)}`); - const base = bases.find((item) => item.provider === "weknora" && item.providerKbId); - assert(base, `缺少 provider-neutral WeKnora KB: ${JSON.stringify(bases, null, 2)}`); - assert.equal(base.defaultToolEnabled, true, "默认 WeKnora KB 应开启 tool 可见性"); - return base; -} - -async function ingest(context, sourcePath) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - force: true, - sources: [{ sourcePath }], - }); - if (!result.ok) { - throw failLayer("mnote_ingest_http", `ingest HTTP ${result.status}`, { response: result.payload || result.text }); - } - const payload = result.payload; - assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora"); - assert.equal(payload?.knowledgeBases?.schema, "mnote.knowledge_bases.registry.v1", "ingest 应返回 knowledgeBases registry"); - const item = (payload.configuredSources || []).find((source) => source.sourceRootRelativePath === sourcePath); - assert(item, `ingest 结果缺少目标 source: ${JSON.stringify(payload.configuredSources, null, 2).slice(0, 1000)}`); - assert.equal(item.mappingStatus, "provider_mapped", `ingest 应 provider_mapped: ${JSON.stringify(item, null, 2)}`); - assert(item.providerKnowledgeBaseId, `ingest 缺少 providerKnowledgeBaseId: ${JSON.stringify(item, null, 2)}`); - assert(item.providerKnowledgeId, `ingest 缺少 providerKnowledgeId: ${JSON.stringify(item, null, 2)}`); - const registryEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === sourcePath); - assert(registryEntry, "source registry 应写入目标 source"); - assert.equal(registryEntry.provider, "weknora", `source registry 应写 provider: ${JSON.stringify(registryEntry, null, 2)}`); - assert.equal(registryEntry.providerKnowledgeBaseId, item.providerKnowledgeBaseId, "source registry 应写 providerKnowledgeBaseId"); - assert.equal(registryEntry.providerKnowledgeId, item.providerKnowledgeId, "source registry 应写 providerKnowledgeId"); - assert.equal(registryEntry.providerSourceId, item.providerKnowledgeId, "source registry 应写 providerSourceId"); - assert(registryEntry.lightRagDocId, "旧 lightRagDocId 兼容字段仍应保留 provider knowledge id"); - return { payload, item, registryEntry }; -} - -async function search(context, marker, sourcePath) { - const startedAt = Date.now(); - let lastPayload = null; - while (Date.now() - startedAt < POLL_TIMEOUT_MS) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - query: marker, - mode: "hybrid", - topK: 10, - chunkTopK: 10, - includeChunkContent: true, - sourcePaths: [sourcePath], - }); - assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`); - lastPayload = result.payload; - const hit = (lastPayload.references || []).find((reference) => { - const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`; - return reference.provider === "weknora" && (reference.sourceRootRelativePath === sourcePath || quote.includes(marker)); - }); - if (hit) return { payload: lastPayload, reference: hit }; - await sleep(POLL_INTERVAL_MS); - } - throw failLayer("weknora_search_timeout", "等待 WeKnora search 返回 task781 source 超时", { marker, sourcePath, lastPayload }); -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - if (!fs.existsSync(ROOT_PATH)) { - throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`); - } - - const context = await request.newContext({ baseURL: BASE_URL }); - try { - await signIn(context); - const beforeStatus = await status(context); - const baseBefore = assertKnowledgeBases(beforeStatus); - - const startedAt = Date.now(); - const marker = `task781 knowledge bases registry marker ${startedAt}`; - const sourcePath = `${FIXTURE_DIR}/task781-knowledge-base-registry-${startedAt}.md`; - const absoluteSourcePath = path.join(ROOT_PATH, sourcePath); - fs.mkdirSync(path.dirname(absoluteSourcePath), { recursive: true }); - fs.writeFileSync( - absoluteSourcePath, - [ - "# Task 781 Knowledge Base Registry", - "", - marker, - "", - "This file verifies MNote provider-neutral knowledge base and source registry boundaries.", - "", - ].join("\n"), - "utf8", - ); - - const ingestResult = await ingest(context, sourcePath); - const searchResult = await search(context, marker, sourcePath); - const afterStatus = await status(context); - const baseAfter = assertKnowledgeBases(afterStatus); - - const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json"); - assert(fs.existsSync(persistedRegistryPath), `应持久化 mnote_knowledge_bases registry: ${persistedRegistryPath}`); - const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8")); - assert.equal(persistedRegistry.schema, "mnote.knowledge_bases.registry.v1", "持久化 KB registry schema 不正确"); - assert((persistedRegistry.bases || []).some((base) => base.provider === "weknora" && base.providerKbId), "持久化 KB registry 缺少 WeKnora base"); - - const result = { - ok: true, - task: "task781-knowledge-bases-registry-smoke", - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - sourcePath, - absoluteSourcePath, - marker, - knowledgeBase: { - before: { - baseId: baseBefore.baseId, - provider: baseBefore.provider, - providerKbId: baseBefore.providerKbId, - defaultToolEnabled: baseBefore.defaultToolEnabled, - }, - after: { - baseId: baseAfter.baseId, - provider: baseAfter.provider, - providerKbId: baseAfter.providerKbId, - defaultToolEnabled: baseAfter.defaultToolEnabled, - sourceCount: baseAfter.sourceCount, - }, - persistedRegistryPath, - }, - sourceRegistry: { - provider: ingestResult.registryEntry.provider, - providerStatus: ingestResult.registryEntry.providerStatus, - providerKnowledgeBaseId: ingestResult.registryEntry.providerKnowledgeBaseId, - providerKnowledgeId: ingestResult.registryEntry.providerKnowledgeId, - providerSourceId: ingestResult.registryEntry.providerSourceId, - lightRagDocIdCompat: ingestResult.registryEntry.lightRagDocId, - }, - search: { - provider: searchResult.payload.provider, - referenceProvider: searchResult.reference.provider, - sourceRootRelativePath: searchResult.reference.sourceRootRelativePath, - providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId, - providerKnowledgeId: searchResult.reference.providerKnowledgeId, - providerChunkId: searchResult.reference.providerChunkId, - }, - }; - writeJson(RESULT_PATH, result); - console.log(JSON.stringify(result, null, 2)); - } finally { - await context.dispose().catch(() => undefined); - } -} - -main().catch((error) => { - const payload = { - ok: false, - task: "task781-knowledge-bases-registry-smoke", - layer: error.layer || "unexpected", - error: error.stack || error.message || String(error), - details: error.details || null, - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - }; - writeJson(FAILURE_PATH, payload); - console.error(JSON.stringify(payload, null, 2)); - process.exit(1); -}); diff --git a/scripts/task783-weknora-kb-settings-ui-static-smoke.js b/scripts/task783-weknora-kb-settings-ui-static-smoke.js deleted file mode 100644 index 64abc084..00000000 --- a/scripts/task783-weknora-kb-settings-ui-static-smoke.js +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); - -const ROOT = process.cwd(); -const OUTPUT_DIR = path.join(ROOT, "tmp", "task783-weknora-kb-settings-ui-static-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const SETTINGS_RUNTIME = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js"; -const SIDEBAR_RUNTIME = "rust/crates/mnote-web/browser/sidebar-tree-runtime.js"; - -function read(relativePath) { - return fs.readFileSync(path.join(ROOT, relativePath), "utf8"); -} - -function assertContains(content, needle, label) { - assert(content.includes(needle), `${label} 缺少: ${needle}`); -} - -function assertOrdered(content, first, second, label) { - const firstIndex = content.indexOf(first); - const secondIndex = content.indexOf(second); - assert(firstIndex >= 0, `${label} 缺少: ${first}`); - assert(secondIndex >= 0, `${label} 缺少: ${second}`); - assert(firstIndex < secondIndex, `${label} 顺序错误: ${first} 应早于 ${second}`); -} - -function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const settings = read(SETTINGS_RUNTIME); - const sidebar = read(SIDEBAR_RUNTIME); - - for (const required of [ - "data-mnote-weknora-page-replacement=\"true\"", - "mnote-weknora-kb-page", - "data-testid=\"mnote-weknora-kb-list-pane\"", - "data-testid=\"mnote-weknora-kb-card-list\"", - "data-knowledge-rag-action=\"select-kb\"", - "data-testid=\"mnote-weknora-kb-detail-pane\"", - "data-testid=\"mnote-weknora-kb-detail-hero\"", - "data-weknora-document-list-view=\"true\"", - "知识库列表", - "创建知识库", - "上传/索引到 WeKnora", - "data-testid=\"mnote-weknora-kb-summary\"", - "data-knowledge-rag-kb-provider=\"weknora\"", - "data-knowledge-rag-kb-id", - "data-knowledge-rag-kb-ids", - "data-knowledge-rag-processing-count", - "data-knowledge-rag-source-picker=\"mnote-filetree\"", - "data-knowledge-rag-source-kind=\"local-file-or-folder\"", - "本地文件或文件夹,例如 docs/book.pdf / docs", - "providerKnowledgeBaseId", - "providerKnowledgeId", - "provider_mapped", - "data-knowledge-rag-kb-id", - "data-knowledge-rag-chunk-id", - "data-kb-rag-search-result=\"true\"", - "data-knowledge-rag-action=\"open-source-reference\"", - "data-knowledge-rag-open-reference-mode=\"search-result-open-reference\"", - "data-mnote-knowledge-rag-open-reference-mode", - "source-registry-reference", - "openPrimaryDocument", - "resourcePath", - "删除 provider index 与 registry 映射,不删除本地文件", - ]) { - assertContains(settings, required, SETTINGS_RUNTIME); - } - - assertContains(sidebar, "openKnowledgeRagSourceReference", SIDEBAR_RUNTIME); - assertContains(sidebar, "knowledgeRagActionName === 'open-source-reference'", SIDEBAR_RUNTIME); - assertContains(sidebar, "knowledgeRagActionName === 'select-kb'", SIDEBAR_RUNTIME); - assertContains(sidebar, "knowledgeRagActionName === 'focus-create-kb'", SIDEBAR_RUNTIME); - - assertOrdered( - settings, - "knowledgeRagKbSummaryHtml(status, entries, providerDocs)", - "
        Provider", - "KB summary 应显示在 provider 详情前", - ); - assertOrdered( - settings, - "data-knowledge-rag-action=\"open-source-reference\"", - "data-knowledge-rag-action=\"delete-source\"", - "source row 应先提供打开引用,再提供删除索引", - ); - - const result = { - ok: true, - checkedFiles: [SETTINGS_RUNTIME, SIDEBAR_RUNTIME], - assertions: { - kbSummaryVisible: true, - weknoraPageReplacementVisible: true, - weknoraKbListPaneVisible: true, - weknoraKbDetailPaneVisible: true, - weknoraDocumentListViewVisible: true, - fileTreePickerBoundary: true, - processingStateVisible: true, - searchResultOpenReference: true, - sourceRowOpenReference: true, - deletePreservesLocalFileCopy: true, - noBackendKnowledgeRagEditRequired: true, - }, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); -} - -main(); diff --git a/scripts/task784-weknora-kb-settings-browser-smoke.js b/scripts/task784-weknora-kb-settings-browser-smoke.js deleted file mode 100644 index c827b642..00000000 --- a/scripts/task784-weknora-kb-settings-browser-smoke.js +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -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 TASK772_RESULT_PATH = path.join(process.cwd(), "tmp", "task772-weknora-ingest-search-open-reference-e2e", "result.json"); -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task784-weknora-kb-settings-browser-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-settings.png"); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); -const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH - || ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] - .find((candidate) => fs.existsSync(candidate)); - -function loadTask772Result() { - assert(fs.existsSync(TASK772_RESULT_PATH), `缺少 task772 真实入库结果: ${TASK772_RESULT_PATH}`); - return JSON.parse(fs.readFileSync(TASK772_RESULT_PATH, "utf8")); -} - -async function signIn(context) { - const response = await context.request.post(`${BASE_URL}/api/auth`, { - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { - email: "mnote.e2e@example.com", - password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", - flow: "signIn", - }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`); -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const fixture = loadTask772Result(); - assert.equal(fixture.provider, "weknora", `task772 provider 不是 weknora: ${JSON.stringify(fixture, null, 2).slice(0, 1200)}`); - assert(fixture.rootUri && fixture.workspaceId && fixture.sourcePath && fixture.marker, "task772 结果缺少 rootUri/workspaceId/sourcePath/marker"); - - const browser = await chromium.launch({ - headless: true, - ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), - }); - const context = await browser.newContext({ viewport: { width: 1360, height: 900 } }); - const page = await context.newPage(); - - try { - await signIn(context); - const url = new URL(`${BASE_URL}/`); - url.searchParams.set("sourceKind", "local_folder"); - url.searchParams.set("rootUri", fixture.rootUri); - url.searchParams.set("workspaceId", fixture.workspaceId); - url.searchParams.set("treeView", "filetree"); - await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - - await page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]').click({ timeout: UI_TIMEOUT_MS }); - const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]'); - await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').click({ timeout: UI_TIMEOUT_MS }); - - const sourceRow = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${fixture.sourcePath}"]`).first(); - await sourceRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await sourceRow.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await sourceRow.locator('[data-knowledge-rag-action="delete-source"][data-knowledge-rag-delete-local-file-policy="preserve-local-file"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]'); - await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids"); - assert(String(kbIds || "").includes(fixture.providerKnowledgeBaseId), `KB summary 未包含 task772 KB id: ${kbIds}`); - - await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS }); - await panel.locator('[data-kb-rag-search-input]').fill(fixture.marker, { timeout: UI_TIMEOUT_MS }); - await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS }); - const searchResult = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-source-path="${fixture.sourcePath}"]`).first(); - await searchResult.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - await searchResult.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); - const result = { - ok: true, - baseUrl: BASE_URL, - rootUri: fixture.rootUri, - workspaceId: fixture.workspaceId, - sourcePath: fixture.sourcePath, - providerKnowledgeBaseId: fixture.providerKnowledgeBaseId, - providerKnowledgeId: fixture.providerKnowledgeId, - kbSummaryVisible: true, - sourceRowVisible: true, - searchResultVisible: true, - openReferenceControlsVisible: true, - deletePreserveLocalFileControlVisible: true, - screenshot: SCREENSHOT_PATH, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); - } finally { - await browser.close().catch(() => undefined); - } -} - -main().catch((error) => { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - fs.writeFileSync( - path.join(OUTPUT_DIR, "failure.json"), - `${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`, - "utf8", - ); - console.error(error.stack || error.message || String(error)); - process.exit(1); -}); diff --git a/scripts/task785-weknora-create-kb-folder-ingest-search-smoke.js b/scripts/task785-weknora-create-kb-folder-ingest-search-smoke.js deleted file mode 100644 index 06d72b31..00000000 --- a/scripts/task785-weknora-create-kb-folder-ingest-search-smoke.js +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); -const { request } = require("playwright"); - -const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; -const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`; -const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space"; -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task785-weknora-create-kb-folder-ingest-search-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json"); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); -const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK785_WEKNORA_TIMEOUT_MS || 240_000); -const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK785_WEKNORA_POLL_MS || 5_000); - -function writeJson(filePath, payload) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function failLayer(layer, message, details = {}) { - const error = new Error(message); - error.layer = layer; - error.details = details; - return error; -} - -async function signIn(context) { - const response = await context.post(`${BASE_URL}/api/auth`, { - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { - email: "mnote.e2e@example.com", - password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", - flow: "signIn", - }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`); -} - -async function apiJson(context, method, url, data) { - const response = await context.fetch(url, { - method, - data, - headers: { - "x-mnote-actor-id": "mnote-e2e", - "x-mnote-actor-type": "user", - "x-mnote-workspace-id": WORKSPACE_ID, - accept: "application/json", - }, - timeout: UI_TIMEOUT_MS, - }); - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = { rawText: text }; - } - return { ok: response.ok(), status: response.status(), payload, text }; -} - -async function status(context) { - const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); - const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); - assert(result.ok, `status HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`); - return result.payload; -} - -async function createKnowledgeBase(context, name, description) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - name, - description, - defaultToolEnabled: true, - }); - if (!result.ok) { - throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text }); - } - const payload = result.payload; - assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora"); - assert.equal(payload?.schema, "mnote.knowledge_rag.create_knowledge_base_result.v1", "create KB schema 不正确"); - assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`); - assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora"); - assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId"); - assert.equal(payload?.knowledgeBase?.defaultToolEnabled, true, "新 KB 应默认启用 tool"); - return payload; -} - -async function ingestFolder(context, folderPath, providerKnowledgeBaseId) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - force: true, - providerKnowledgeBaseId, - sources: [{ sourcePath: folderPath }], - }); - if (!result.ok) { - throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text }); - } - const payload = result.payload; - assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora"); - const configured = payload.configuredSources || []; - assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1200)}`); - const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId); - assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`); - const registryEntries = payload.registry?.entries || []; - const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`)); - assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source"); - assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora"); - assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id"); - return { payload, configured, mapped, folderEntries }; -} - -async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) { - const startedAt = Date.now(); - let lastPayload = null; - while (Date.now() - startedAt < POLL_TIMEOUT_MS) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - query, - mode: "hybrid", - topK: 10, - chunkTopK: 10, - includeChunkContent: true, - providerKnowledgeBaseId, - sourcePaths: [folderPath], - }); - assert(result.ok, `search HTTP ${result.status}: ${JSON.stringify(result.payload || result.text).slice(0, 1000)}`); - lastPayload = result.payload; - const hit = (lastPayload.references || []).find((reference) => { - const pathText = String(reference.sourceRootRelativePath || ""); - const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`; - return reference.provider === "weknora" - && reference.providerKnowledgeBaseId === providerKnowledgeBaseId - && (pathText.startsWith(`${folderPath}/`) || quote.includes(query)); - }); - if (hit) return { payload: lastPayload, reference: hit }; - await sleep(POLL_INTERVAL_MS); - } - throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", { - query, - folderPath, - providerKnowledgeBaseId, - lastPayload, - }); -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - if (!fs.existsSync(ROOT_PATH)) { - throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`); - } - const context = await request.newContext({ baseURL: BASE_URL }); - try { - await signIn(context); - const startedAt = Date.now(); - const markerA = `task785 create kb folder marker alpha ${startedAt}`; - const markerB = `task785 create kb folder marker beta ${startedAt}`; - const folderPath = `knowledge-rag-fixtures-7-68/task785-folder-${startedAt}`; - const absoluteFolderPath = path.join(ROOT_PATH, folderPath); - fs.mkdirSync(absoluteFolderPath, { recursive: true }); - fs.writeFileSync(path.join(absoluteFolderPath, "alpha.md"), `# Task 785 Alpha\n\n${markerA}\n`, "utf8"); - fs.writeFileSync(path.join(absoluteFolderPath, "beta.md"), `# Task 785 Beta\n\n${markerB}\n`, "utf8"); - - const name = `task785-weknora-kb-${startedAt}`; - const createResult = await createKnowledgeBase(context, name, "MNote task785 temporary KB created through MNote facade"); - const providerKnowledgeBaseId = createResult.providerKnowledgeBaseId; - const ingestResult = await ingestFolder(context, folderPath, providerKnowledgeBaseId); - const searchResult = await searchUntilHit(context, markerA, folderPath, providerKnowledgeBaseId); - const afterStatus = await status(context); - const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId); - assert(statusBase, "status knowledgeBases 应包含新建 KB"); - assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`); - - const persistedRegistryPath = path.join(ROOT_PATH, ".mnote", "index", "mnote-knowledge-bases.json"); - const persistedRegistry = JSON.parse(fs.readFileSync(persistedRegistryPath, "utf8")); - assert((persistedRegistry.bases || []).some((base) => base.providerKbId === providerKnowledgeBaseId), "持久化 KB registry 应包含新建 KB"); - - const result = { - ok: true, - task: "task785-weknora-create-kb-folder-ingest-search-smoke", - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - folderPath, - absoluteFolderPath, - markers: [markerA, markerB], - knowledgeBase: { - name, - providerKnowledgeBaseId, - baseId: createResult.knowledgeBase?.baseId, - defaultToolEnabled: createResult.knowledgeBase?.defaultToolEnabled, - sourceCount: statusBase.sourceCount, - status: statusBase.status, - }, - ingest: { - configuredCount: ingestResult.configured.length, - mappedCount: ingestResult.mapped.length, - sourcePaths: ingestResult.mapped.map((item) => item.sourceRootRelativePath), - }, - search: { - provider: searchResult.payload.provider, - referenceProvider: searchResult.reference.provider, - sourceRootRelativePath: searchResult.reference.sourceRootRelativePath, - providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId, - providerKnowledgeId: searchResult.reference.providerKnowledgeId, - providerChunkId: searchResult.reference.providerChunkId, - }, - persistedRegistryPath, - }; - writeJson(RESULT_PATH, result); - console.log(JSON.stringify(result, null, 2)); - } finally { - await context.dispose().catch(() => undefined); - } -} - -main().catch((error) => { - const payload = { - ok: false, - task: "task785-weknora-create-kb-folder-ingest-search-smoke", - layer: error.layer || "unexpected", - error: error.stack || error.message || String(error), - details: error.details || null, - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - }; - writeJson(FAILURE_PATH, payload); - console.error(JSON.stringify(payload, null, 2)); - process.exit(1); -}); diff --git a/scripts/task787-weknora-default-lightrag-legacy-smoke.js b/scripts/task787-weknora-default-lightrag-legacy-smoke.js deleted file mode 100644 index a1ac7194..00000000 --- a/scripts/task787-weknora-default-lightrag-legacy-smoke.js +++ /dev/null @@ -1,272 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); - -const ROOT = process.cwd(); -const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const OUTPUT_DIR = path.join(ROOT, "tmp", "task787-weknora-default-lightrag-legacy-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json"); -const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_TASK787_STATUS_TIMEOUT_MS || 15_000); - -const REQUIRED_STATIC_FILES = [ - "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js", - "rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js", - "scripts/TESTING_REFERENCE.md", -]; - -const TASK_SMOKE_PATTERN = /^task(?:53|54|76|78)\d.*\.js$/; -const SELF_PATH = "scripts/task787-weknora-default-lightrag-legacy-smoke.js"; - -function writeJson(filePath, payload) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); -} - -function listTaskSmokeFiles() { - return fs.readdirSync(path.join(ROOT, "scripts"), { withFileTypes: true }) - .filter((entry) => entry.isFile() && TASK_SMOKE_PATTERN.test(entry.name)) - .map((entry) => `scripts/${entry.name}`) - .sort((left, right) => left.localeCompare(right)); -} - -function uniqueSorted(values) { - return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right)); -} - -function extractWindow(line, start, length) { - const from = Math.max(0, start - 90); - const to = Math.min(line.length, start + length + 90); - return line.slice(from, to).trim(); -} - -function allowedLegacyContext(contextBlock, occurrenceWindow) { - const lowerBlock = contextBlock.toLowerCase(); - const lowerWindow = occurrenceWindow.toLowerCase(); - const legacyMarkers = [ - "legacy", - "fallback", - "history", - "historical", - "debug", - "compat", - "retired", - "assertnotcontains", - "assertnotincludes", - "forbidden", - "notcontains", - "notincludes", - "not contain", - "does not include", - "no lightrag", - "nolightrag", - "nodefaultlightrag", - "without lightrag", - "bypass", - "bypasses", - "skip", - "旧", - "历史", - "兼容", - "调试", - "退役", - "软归档", - "不应包含", - "不作为", - "不再", - "不直接", - "不删除", - "不走", - "禁止", - "绕过", - "跳过", - "移除", - "缺少", - ]; - return legacyMarkers.some((marker) => lowerWindow.includes(marker) || lowerBlock.includes(marker)); -} - -function ignoredTechnicalField(line) { - return /\blightRag(?:DocId|FilePath|Status)\b/.test(line) - || /lightrag-source-registry\.json/.test(line); -} - -function ignoredLegacyAssetName(line) { - return /task\d+-lightrag-[\w-]+/.test(line); -} - -function ignoredNegativeAssertionName(line) { - return /\b(?:uiNoDefault|ingestNo|deleteNo|sectionContextNo|noDefault)LightRag[A-Za-z0-9_]*\b/.test(line); -} - -function classifyActiveLightRagContext(contextBlock, occurrenceWindow) { - const normalized = `${contextBlock}\n${occurrenceWindow}`.toLowerCase(); - const reasons = []; - const activeDefaultPatterns = [ - /默认\s*provider/i, - /唯一默认/i, - /主线\s*provider/i, - /default\s*provider/i, - /primary\s*provider/i, - /mainline\s*provider/i, - /only\s*default/i, - ]; - if (activeDefaultPatterns.some((pattern) => pattern.test(contextBlock) || pattern.test(occurrenceWindow))) { - reasons.push("forbidden_default_or_mainline_provider_copy"); - } - const activeSurfacePatterns = [ - /资料库/, - /知识库/, - /问答/, - /检索/, - /搜索/, - /引用/, - /入库/, - /dashboard/, - /health/, - /status/, - /source[-_ ]?registry/, - /search\s*provider/, - /references?/, - /retrieval/, - /smoke/, - ]; - if (activeSurfacePatterns.some((pattern) => pattern.test(normalized))) { - reasons.push("active_lightrag_surface_without_legacy_marker"); - } - if (reasons.length === 0) { - reasons.push("unqualified_lightrag_reference_without_legacy_marker"); - } - return reasons; -} - -function scanStaticFiles(files) { - const violations = []; - const scannedFiles = []; - for (const relativePath of files) { - const absolutePath = path.join(ROOT, relativePath); - if (!fs.existsSync(absolutePath)) { - violations.push({ - file: relativePath, - line: 0, - reason: "static_file_missing", - context: "required static/smoke baseline file is missing", - }); - continue; - } - scannedFiles.push(relativePath); - const content = fs.readFileSync(absolutePath, "utf8"); - const lines = content.split(/\r?\n/); - lines.forEach((line, index) => { - const matches = Array.from(line.matchAll(/lightrag/gi)); - for (const match of matches) { - if (ignoredTechnicalField(line) || ignoredLegacyAssetName(line) || ignoredNegativeAssertionName(line)) { - continue; - } - const occurrenceWindow = extractWindow(line, match.index || 0, match[0].length); - const contextBlock = lines.slice(Math.max(0, index - 8), Math.min(lines.length, index + 5)).join("\n"); - if (allowedLegacyContext(contextBlock, occurrenceWindow)) { - continue; - } - violations.push({ - file: relativePath, - line: index + 1, - reason: classifyActiveLightRagContext(contextBlock, occurrenceWindow), - context: occurrenceWindow, - }); - } - }); - } - return { scannedFiles, violations }; -} - -async function fetchStatusProviderConfig() { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - const url = `${BASE_URL}/api/knowledge-rag/status`; - try { - const response = await fetch(url, { - headers: { accept: "application/json" }, - signal: controller.signal, - }); - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch (error) { - return { - ok: false, - layer: "status_api_invalid_json", - url, - status: response.status, - error: error.message, - rawText: text.slice(0, 1000), - providerConfig: null, - }; - } - const providerConfig = payload && typeof payload.providerConfig === "object" ? payload.providerConfig : null; - const active = providerConfig && providerConfig.active; - const defaultProvider = providerConfig && providerConfig.default; - const provider = payload && payload.provider; - const ok = response.ok && providerConfig && active === "weknora" && defaultProvider === "weknora"; - return { - ok, - layer: response.ok ? (ok ? "status_provider_config_ok" : "status_provider_config_mismatch") : "status_api_http_error", - url, - status: response.status, - provider, - providerConfig, - responseSample: ok ? undefined : payload, - }; - } catch (error) { - return { - ok: false, - layer: error && error.name === "AbortError" ? "status_api_timeout" : "status_api_unavailable", - url, - error: error && (error.stack || error.message) ? (error.stack || error.message) : String(error), - providerConfig: null, - }; - } finally { - clearTimeout(timeout); - } -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const staticFiles = uniqueSorted([...REQUIRED_STATIC_FILES, ...listTaskSmokeFiles()]); - const staticScan = scanStaticFiles(staticFiles.filter((relativePath) => relativePath !== SELF_PATH)); - const statusCheck = await fetchStatusProviderConfig(); - const ok = staticScan.violations.length === 0 && statusCheck.ok; - const payload = { - ok, - task: "task787-weknora-default-lightrag-legacy-smoke", - baseUrl: BASE_URL, - checkedAt: new Date().toISOString(), - scannedFiles: staticScan.scannedFiles, - violations: staticScan.violations, - statusProviderConfig: statusCheck, - }; - if (ok) { - writeJson(RESULT_PATH, payload); - console.log(JSON.stringify(payload, null, 2)); - return; - } - writeJson(FAILURE_PATH, payload); - console.error(JSON.stringify(payload, null, 2)); - process.exit(1); -} - -main().catch((error) => { - const payload = { - ok: false, - task: "task787-weknora-default-lightrag-legacy-smoke", - layer: "unexpected", - error: error && (error.stack || error.message) ? (error.stack || error.message) : String(error), - baseUrl: BASE_URL, - }; - writeJson(FAILURE_PATH, payload); - console.error(JSON.stringify(payload, null, 2)); - process.exit(1); -}); diff --git a/scripts/task788-weknora-kb-folder-browser-flow-smoke.js b/scripts/task788-weknora-kb-folder-browser-flow-smoke.js deleted file mode 100644 index 4c4880a7..00000000 --- a/scripts/task788-weknora-kb-folder-browser-flow-smoke.js +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); -const { chromium, request } = require("playwright"); - -const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); -const ROOT_PATH = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; -const ROOT_URI = process.env.MNOTE_KNOWLEDGE_RAG_ROOT_URI || `file://${ROOT_PATH}`; -const WORKSPACE_ID = process.env.MNOTE_KNOWLEDGE_RAG_WORKSPACE_ID || "local-ws:mnote-e2e:my-space"; -const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task788-weknora-kb-folder-browser-flow-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const FAILURE_PATH = path.join(OUTPUT_DIR, "failure.json"); -const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "weknora-kb-folder-browser-flow.png"); -const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); -const POLL_TIMEOUT_MS = Number(process.env.MNOTE_TASK788_WEKNORA_TIMEOUT_MS || 240_000); -const POLL_INTERVAL_MS = Number(process.env.MNOTE_TASK788_WEKNORA_POLL_MS || 5_000); -const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH - || ["/usr/bin/chromium-browser", "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"] - .find((candidate) => fs.existsSync(candidate)); - -function writeJson(filePath, payload) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function failLayer(layer, message, details = {}) { - const error = new Error(message); - error.layer = layer; - error.details = details; - return error; -} - -async function signInRequest(context) { - const response = await context.post(`${BASE_URL}/api/auth`, { - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { - email: "mnote.e2e@example.com", - password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", - flow: "signIn", - }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`); -} - -async function signInBrowser(context) { - const response = await context.request.post(`${BASE_URL}/api/auth`, { - data: { - action: "auth:signIn", - args: { - provider: "password", - params: { - email: "mnote.e2e@example.com", - password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", - flow: "signIn", - }, - }, - }, - timeout: UI_TIMEOUT_MS, - }); - assert(response.ok(), `浏览器上下文登录失败: ${response.status()} ${await response.text()}`); -} - -async function apiJson(context, method, url, data) { - const response = await context.fetch(url, { - method, - data, - headers: { - "x-mnote-actor-id": "mnote-e2e", - "x-mnote-actor-type": "user", - "x-mnote-workspace-id": WORKSPACE_ID, - accept: "application/json", - }, - timeout: UI_TIMEOUT_MS, - }); - const text = await response.text(); - let payload = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = { rawText: text }; - } - return { ok: response.ok(), status: response.status(), payload, text }; -} - -async function status(context) { - const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); - const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); - if (!result.ok) { - throw failLayer("mnote_status_http", `status HTTP ${result.status}`, { response: result.payload || result.text }); - } - return result.payload; -} - -async function createKnowledgeBase(context, name, description) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/knowledge-bases`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - name, - description, - defaultToolEnabled: true, - }); - if (!result.ok) { - throw failLayer("mnote_create_kb_http", `create KB HTTP ${result.status}`, { response: result.payload || result.text }); - } - const payload = result.payload; - assert.equal(payload?.provider, "weknora", "create KB provider 应为 weknora"); - assert(payload?.providerKnowledgeBaseId, `create KB 缺少 providerKnowledgeBaseId: ${JSON.stringify(payload, null, 2).slice(0, 1200)}`); - assert.equal(payload?.knowledgeBase?.provider, "weknora", "knowledgeBase provider 应为 weknora"); - assert.equal(payload?.knowledgeBase?.providerKbId, payload.providerKnowledgeBaseId, "knowledgeBase.providerKbId 应匹配 providerKnowledgeBaseId"); - return payload; -} - -async function ingestFolder(context, folderPath, providerKnowledgeBaseId) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/ingest`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - force: true, - providerKnowledgeBaseId, - sources: [{ sourcePath: folderPath }], - }); - if (!result.ok) { - throw failLayer("mnote_ingest_folder_http", `ingest folder HTTP ${result.status}`, { response: result.payload || result.text }); - } - const payload = result.payload; - assert.equal(payload?.provider, "weknora", "ingest provider 应为 weknora"); - const configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : []; - assert(configured.length >= 2, `文件夹入库应至少展开两个文件: ${JSON.stringify(configured, null, 2).slice(0, 1600)}`); - const mapped = configured.filter((item) => item.mappingStatus === "provider_mapped" && item.providerKnowledgeBaseId === providerKnowledgeBaseId); - assert(mapped.length >= 2, `文件夹入库未全部映射到新 KB: ${JSON.stringify(configured, null, 2).slice(0, 2000)}`); - const registryEntries = payload.registry?.entries || []; - const folderEntries = registryEntries.filter((entry) => String(entry.sourceRootRelativePath || "").startsWith(`${folderPath}/`)); - assert(folderEntries.length >= 2, "source registry 应写入文件夹下的 source"); - assert(folderEntries.every((entry) => entry.provider === "weknora"), "source registry provider 应为 weknora"); - assert(folderEntries.every((entry) => entry.providerKnowledgeBaseId === providerKnowledgeBaseId), "source registry 应写入新 KB id"); - return { payload, configured, mapped, folderEntries }; -} - -async function searchUntilHit(context, query, folderPath, providerKnowledgeBaseId) { - const startedAt = Date.now(); - let lastPayload = null; - while (Date.now() - startedAt < POLL_TIMEOUT_MS) { - const result = await apiJson(context, "POST", `${BASE_URL}/api/knowledge-rag/search`, { - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - query, - mode: "hybrid", - topK: 10, - chunkTopK: 10, - includeChunkContent: true, - providerKnowledgeBaseId, - sourcePaths: [folderPath], - }); - if (!result.ok) { - throw failLayer("mnote_search_http", `search HTTP ${result.status}`, { response: result.payload || result.text }); - } - lastPayload = result.payload; - const hit = (lastPayload.references || []).find((reference) => { - const pathText = String(reference.sourceRootRelativePath || ""); - const quote = `${reference.rawQuote || ""}\n${reference.displayQuote || ""}\n${reference.quote || ""}`; - return reference.provider === "weknora" - && reference.providerKnowledgeBaseId === providerKnowledgeBaseId - && (pathText.startsWith(`${folderPath}/`) || quote.includes(query)); - }); - if (hit) return { payload: lastPayload, reference: hit }; - await sleep(POLL_INTERVAL_MS); - } - throw failLayer("weknora_new_kb_folder_search_timeout", "等待新 KB 文件夹入库内容可搜索超时", { - query, - folderPath, - providerKnowledgeBaseId, - lastPayload, - }); -} - -function createFolderFixture(startedAt) { - if (!fs.existsSync(ROOT_PATH)) { - throw failLayer("allowed_root_missing", `allowed root 不存在: ${ROOT_PATH}`); - } - const marker = `task788 weknora kb folder browser marker ${startedAt}`; - const markers = [`${marker} alpha`, `${marker} beta`]; - const folderPath = `knowledge-rag-fixtures-7-68/task788-folder-${startedAt}`; - const absoluteFolderPath = path.join(ROOT_PATH, folderPath); - const sourcePaths = [`${folderPath}/alpha.md`, `${folderPath}/beta.md`]; - fs.mkdirSync(absoluteFolderPath, { recursive: true }); - fs.writeFileSync( - path.join(absoluteFolderPath, "alpha.md"), - `# Task 788 Alpha\n\n${markers[0]}\n\nWeKnora folder browser flow alpha note.\n`, - "utf8", - ); - fs.writeFileSync( - path.join(absoluteFolderPath, "beta.md"), - `# Task 788 Beta\n\n${markers[1]}\n\nWeKnora folder browser flow beta note.\n`, - "utf8", - ); - return { folderPath, absoluteFolderPath, sourcePaths, marker, markers }; -} - -async function verifyInBrowser(fixture, name) { - const browser = await chromium.launch({ - headless: true, - ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}), - }); - const context = await browser.newContext({ viewport: { width: 1360, height: 900 } }); - const page = await context.newPage(); - try { - await signInBrowser(context); - const url = new URL(`${BASE_URL}/`); - url.searchParams.set("sourceKind", "local_folder"); - url.searchParams.set("rootUri", ROOT_URI); - url.searchParams.set("workspaceId", WORKSPACE_ID); - url.searchParams.set("treeView", "filetree"); - await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - - const knowledgeEntry = page.locator('[data-testid="mnote-knowledge-rag-settings-toggle"]'); - assert.equal(await knowledgeEntry.getAttribute("href"), "/knowledge", "主知识库入口应打开 WeKnora 原生 host"); - assert.equal(await knowledgeEntry.getAttribute("data-mnote-action"), "open-knowledge-host", "主知识库入口不应再打开旧设置壳"); - await page.goto(`${BASE_URL}/debug/knowledge-rag`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); - const panel = page.locator('[data-testid="mnote-weknora-knowledge-settings-panel"]'); - await panel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - assert.equal(await panel.getAttribute("data-knowledge-rag-default-provider"), "weknora", "设置面板应声明 WeKnora provider"); - - await panel.locator('[data-testid="mnote-weknora-create-kb-name"]').fill(name); - await panel.locator('[data-testid="mnote-weknora-create-kb-description"]').fill("MNote task788 temporary KB created through browser settings UI"); - await panel.locator('[data-knowledge-rag-action="create-kb"]').click({ timeout: UI_TIMEOUT_MS }); - const kbSelect = panel.locator('[data-testid="mnote-weknora-kb-select"]'); - await page.waitForFunction( - (expectedName) => { - const status = document.querySelector('[data-testid="mnote-weknora-create-kb-status"]'); - if (!(status instanceof HTMLElement) || status.getAttribute("data-status") !== "done") return false; - const select = document.querySelector('[data-testid="mnote-weknora-kb-select"]'); - if (!(select instanceof HTMLSelectElement) || !select.value.trim()) return false; - const selected = select.options[select.selectedIndex]; - return selected && selected.textContent.includes(expectedName); - }, - name, - { timeout: UI_TIMEOUT_MS }, - ); - const providerKnowledgeBaseId = await kbSelect.inputValue(); - assert(providerKnowledgeBaseId, "UI 创建 KB 后应选中新 providerKnowledgeBaseId"); - - const firstSourceInput = panel.locator('[data-knowledge-rag-source-input]').first(); - await firstSourceInput.fill(fixture.folderPath); - await panel.locator('[data-knowledge-rag-action="ingest"]').click({ timeout: UI_TIMEOUT_MS }); - await page.locator('[data-knowledge-rag-action="filter-sources"][data-knowledge-rag-filter="all"]').first().click({ timeout: UI_TIMEOUT_MS }); - const sourceRows = await Promise.all(fixture.sourcePaths.map(async (sourcePath) => { - const row = page.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-source-path="${sourcePath}"]`).first(); - await row.waitFor({ state: "visible", timeout: POLL_TIMEOUT_MS }); - await row.locator('[data-knowledge-rag-action="open-source-reference"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - return sourcePath; - })); - - const kbSummary = panel.locator('[data-testid="mnote-weknora-kb-summary"]'); - await kbSummary.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - const kbIds = await kbSummary.getAttribute("data-knowledge-rag-kb-ids"); - const sourceRowsForKb = await panel.locator(`[data-knowledge-rag-source-row="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).count(); - assert(sourceRowsForKb >= 2, `source rows 未反映新建 KB id: ${providerKnowledgeBaseId}`); - - await panel.locator('[data-kb-rag-tab="search"]').click({ timeout: UI_TIMEOUT_MS }); - await panel.locator('[data-kb-rag-search-input]').fill(fixture.markers[0]); - let searchResultSourcePath = null; - const startedAt = Date.now(); - while (Date.now() - startedAt < POLL_TIMEOUT_MS) { - await panel.locator('[data-kb-rag-action="search"]').click({ timeout: UI_TIMEOUT_MS }); - const result = panel.locator(`[data-kb-rag-search-result="true"][data-knowledge-rag-kb-id="${providerKnowledgeBaseId}"]`).first(); - try { - await result.waitFor({ state: "visible", timeout: POLL_INTERVAL_MS }); - searchResultSourcePath = await result.getAttribute("data-knowledge-rag-source-path"); - break; - } catch { - await sleep(POLL_INTERVAL_MS); - } - } - assert(searchResultSourcePath, `UI 检索未展示新 KB ${providerKnowledgeBaseId} 的结果`); - await panel.locator('[data-kb-rag-search-result="true"] [data-knowledge-rag-action="open-source-reference"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); - - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); - return { - providerKnowledgeBaseId, - kbSummaryVisible: true, - kbSummaryConfiguredKbIds: String(kbIds || "").split(",").filter(Boolean), - sourceRowsForKb, - sourceRowsVisible: sourceRows, - searchResultVisible: true, - searchResultSourcePath, - searchVerifiedByApi: false, - searchUiProviderKbScopeMissing: false, - openReferenceControlsVisible: true, - screenshot: SCREENSHOT_PATH, - }; - } catch (error) { - await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }).catch(() => undefined); - throw failLayer("browser_ui_verify", error.message || String(error), { screenshot: SCREENSHOT_PATH }); - } finally { - await browser.close().catch(() => undefined); - } -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const context = await request.newContext({ baseURL: BASE_URL }); - try { - await signInRequest(context); - const startedAt = Date.now(); - const fixture = createFolderFixture(startedAt); - const name = `task788-weknora-kb-${startedAt}`; - const browserResult = await verifyInBrowser(fixture, name); - const providerKnowledgeBaseId = browserResult.providerKnowledgeBaseId; - const searchResult = await searchUntilHit(context, fixture.markers[0], fixture.folderPath, providerKnowledgeBaseId); - const afterStatus = await status(context); - const statusBase = (afterStatus.knowledgeBases?.bases || []).find((base) => base.providerKbId === providerKnowledgeBaseId); - assert(statusBase, "status knowledgeBases 应包含新建 KB"); - assert(statusBase.sourceCount >= 2, `新建 KB sourceCount 应 >= 2: ${JSON.stringify(statusBase, null, 2)}`); - - const result = { - ok: true, - task: "task788-weknora-kb-folder-browser-flow-smoke", - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - uiCreatesKb: true, - uiAddsFolder: true, - browserVerifiesKb: true, - browserVerifiesSearch: true, - browserVerifiesOpenReferenceControls: true, - apiVerifiesSearch: true, - folderPath: fixture.folderPath, - absoluteFolderPath: fixture.absoluteFolderPath, - markers: fixture.markers, - knowledgeBase: { - name, - providerKnowledgeBaseId, - baseId: statusBase.baseId, - defaultToolEnabled: statusBase.defaultToolEnabled, - sourceCount: statusBase.sourceCount, - status: statusBase.status, - }, - ingest: { - configuredCount: fixture.sourcePaths.length, - mappedCount: fixture.sourcePaths.length, - sourcePaths: fixture.sourcePaths, - }, - apiSearch: { - provider: searchResult.payload.provider, - sourceRootRelativePath: searchResult.reference.sourceRootRelativePath, - providerKnowledgeBaseId: searchResult.reference.providerKnowledgeBaseId, - providerKnowledgeId: searchResult.reference.providerKnowledgeId, - providerChunkId: searchResult.reference.providerChunkId, - }, - browser: browserResult, - }; - writeJson(RESULT_PATH, result); - console.log(JSON.stringify(result, null, 2)); - } finally { - await context.dispose().catch(() => undefined); - } -} - -main().catch((error) => { - const payload = { - ok: false, - task: "task788-weknora-kb-folder-browser-flow-smoke", - layer: error.layer || "unexpected", - error: error.stack || error.message || String(error), - details: error.details || null, - baseUrl: BASE_URL, - rootUri: ROOT_URI, - workspaceId: WORKSPACE_ID, - }; - writeJson(FAILURE_PATH, payload); - console.error(JSON.stringify(payload, null, 2)); - process.exit(1); -}); diff --git a/scripts/task789-weknora-kb-page-ui-reference-static-smoke.js b/scripts/task789-weknora-kb-page-ui-reference-static-smoke.js deleted file mode 100644 index 3e886933..00000000 --- a/scripts/task789-weknora-kb-page-ui-reference-static-smoke.js +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); - -const ROOT = process.cwd(); -const OUTPUT_DIR = path.join(ROOT, "tmp", "task789-weknora-kb-page-ui-reference-static-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const REFERENCE_DOC = "design/07-ai/reference/7-68-weknora-kb-page-ui-reference-v1.md"; - -function read(relativePath) { - return fs.readFileSync(path.join(ROOT, relativePath), "utf8"); -} - -function assertContains(content, needle) { - assert(content.includes(needle), `reference doc 缺少: ${needle}`); -} - -function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - assert(fs.existsSync(path.join(ROOT, REFERENCE_DOC)), `${REFERENCE_DOC} 不存在`); - - const doc = read(REFERENCE_DOC); - for (const required of [ - "/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBaseList.vue", - "/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/KnowledgeBase.vue", - "/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/DocumentListView.vue", - "/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/views/knowledge/components/KbUploadSourceDropdown.vue", - "/mnt/Data1T/Mnote_data/weknora/WeKnora/frontend/src/components/knowledge-processing-timeline.vue", - "providerKnowledgeId", - "不能直接接管的边界", - "MNote 最小呈现建议", - ]) { - assertContains(doc, required); - } - - const result = { - ok: true, - checkedFiles: [REFERENCE_DOC], - assertions: { - referenceDocExists: true, - weknoraKbListPathRecorded: true, - weknoraKbDetailPathRecorded: true, - documentListPathRecorded: true, - uploadDropdownPathRecorded: true, - processingTimelinePathRecorded: true, - mnoteMappingRecorded: true, - boundariesRecorded: true, - minimumStructureRecorded: true, - }, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); -} - -main(); diff --git a/scripts/task790-weknora-kb-page-experience-static-smoke.js b/scripts/task790-weknora-kb-page-experience-static-smoke.js deleted file mode 100644 index c1e4b656..00000000 --- a/scripts/task790-weknora-kb-page-experience-static-smoke.js +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const assert = require("node:assert"); -const fs = require("node:fs"); -const path = require("node:path"); - -const ROOT = process.cwd(); -const OUTPUT_DIR = path.join(ROOT, "tmp", "task790-weknora-kb-page-experience-static-smoke"); -const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); -const SETTINGS_RUNTIME = "rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js"; -const MAIN_CSS = "rust/crates/mnote-web/src/ssr/styles/components/main.css"; - -function read(relativePath) { - return fs.readFileSync(path.join(ROOT, relativePath), "utf8"); -} - -function assertContains(content, needle, label) { - assert(content.includes(needle), `${label} 缺少: ${needle}`); -} - -function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const runtime = read(SETTINGS_RUNTIME); - const css = read(MAIN_CSS); - - for (const required of [ - "data-mnote-weknora-page-replacement=\"true\"", - "data-testid=\"mnote-weknora-kb-list-pane\"", - "data-testid=\"mnote-weknora-kb-card-list\"", - "data-testid=\"mnote-weknora-kb-breadcrumb\"", - "data-testid=\"mnote-weknora-kb-detail-pane\"", - "data-testid=\"mnote-weknora-tab-documents\"", - "data-testid=\"mnote-weknora-tab-wiki\"", - "data-testid=\"mnote-weknora-tab-graph\"", - "data-testid=\"mnote-weknora-doc-source-sidebar\"", - "data-testid=\"mnote-weknora-doc-source-tags\"", - "data-testid=\"mnote-weknora-doc-tags\"", - "data-testid=\"mnote-weknora-doc-filter-input\"", - "data-testid=\"mnote-weknora-view-switch\"", - "data-knowledge-rag-view=\"grid\"", - "添加文件夹", - "添加文档", - "data-weknora-document-list-view=\"true\"", - "data-knowledge-rag-processing-status", - "data-knowledge-rag-action=\"open-source-reference\"", - "data-knowledge-rag-open-reference-mode=\"source-registry-reference\"", - "data-testid=\"mnote-weknora-wiki-panel\"", - "data-testid=\"mnote-weknora-graph-panel\"", - "MNote source registry / WeKnora KB chunk index", - ]) { - assertContains(runtime, required, SETTINGS_RUNTIME); - } - - for (const required of [ - ".mnote-weknora-kb-breadcrumb", - ".mnote-weknora-doc-layout", - ".mnote-weknora-doc-sidebar", - ".mnote-weknora-doc-source-tags", - ".mnote-weknora-view-switch", - ".mnote-weknora-doc-table[data-weknora-document-view=\"grid\"]", - ".mnote-weknora-placeholder-panel", - ".mnote-weknora-source-aux", - ]) { - assertContains(css, required, MAIN_CSS); - } - - const result = { - ok: true, - checkedFiles: [SETTINGS_RUNTIME, MAIN_CSS], - assertions: { - kbListPageVisible: true, - kbDetailBreadcrumbVisible: true, - documentsWikiGraphTabsVisible: true, - sourceAndTagSidebarVisible: true, - searchAndFilterVisible: true, - addDocumentAndFolderEntrypointsVisible: true, - gridListSwitchVisible: true, - processingStatusVisible: true, - sourceRowsKeepCitationOpenReference: true, - mnoteWeKnoraBoundaryPreserved: true, - }, - }; - fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - console.log(JSON.stringify(result, null, 2)); -} - -main(); diff --git a/scripts/task804-document-read-mode-and-page-ref-conversion-smoke.js b/scripts/task804-document-read-mode-and-page-ref-conversion-smoke.js new file mode 100644 index 00000000..4cdda08d --- /dev/null +++ b/scripts/task804-document-read-mode-and-page-ref-conversion-smoke.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = process.cwd(); +const CONVERSION_RUNTIME = path.join(ROOT, "rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js"); +const SESSION_RUNTIME = fs.readFileSync(path.join(ROOT, "rust/crates/mnote-web/browser/document-session-runtime.js"), "utf8"); +const ADAPTER_RUNTIME = fs.readFileSync(path.join(ROOT, "rust/crates/mnote-web/browser/document-editor-adapter-runtime.js"), "utf8"); + +async function main() { + global.window = { location: { origin: "http://127.0.0.1:3000" } }; + const runtime = await import(`file://${CONVERSION_RUNTIME}?t=${Date.now()}`); + const href = "/documents/local-md:liaibo~E7~9A~84~E4~B8~AA~E4~BA~BA~E7~A9~BA~E9~97~B4~2F~E9~A1~B9~E7~9B~AE~2F~E5~AE~8C~E7~BB~93~E9~A1~B9~E7~9B~AE.md?sourceKind=local_folder&rootUri=file%3A%2F%2F%2Fmnt%2FData1T%2FMnote_data%2Fusers%2Fliaibo%2Fworkspaces%2Fmy-space&treeView=filetree"; + const block = runtime.tiptapNodeToEditorBlock({ + type: "paragraph", + attrs: { blockId: "local-block-1" }, + content: [{ + type: "text", + text: "完结项目", + marks: [{ + type: "link", + attrs: { + href, + target: "_self", + rel: "noopener noreferrer nofollow", + class: "mnote-page-block-link", + }, + }], + }], + }, 0); + + assert.equal(block.blockType, "page_reference"); + assert.equal(block.props.title, "完结项目"); + assert.equal(block.props.sourcePath, "liaibo的个人空间/项目/完结项目.md"); + + assert.match(SESSION_RUNTIME, /userReadOnlyMode/); + assert.match(SESSION_RUNTIME, /setDocumentSessionUserReadOnlyMode/); + assert.match( + SESSION_RUNTIME, + /userReadOnlyMode:\s*true/, + "普通文档打开后必须默认只读;编辑只能由用户显式切换", + ); + assert.doesNotMatch( + SESSION_RUNTIME, + /userReadOnlyMode:\s*false/, + "不能把用户编辑模式作为默认态,否则页面首屏不是只读浏览", + ); + assert.match(ADAPTER_RUNTIME, /data-document-edit-mode-toggle/); + assert.match(ADAPTER_RUNTIME, /开始编辑/); + assert.match(ADAPTER_RUNTIME, /退出编辑/); +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/task805-editor-block-menu-regression-static-smoke.js b/scripts/task805-editor-block-menu-regression-static-smoke.js new file mode 100644 index 00000000..424a6ab3 --- /dev/null +++ b/scripts/task805-editor-block-menu-regression-static-smoke.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = process.cwd(); +const blockMenuPath = path.join( + ROOT, + "rust/spikes/leptos-tiptap-spike/src/editor_runtime/block_handle_menu_view.rs", +); +const routesPath = path.join(ROOT, "rust/crates/mnote-web/src/routes/mod.rs"); +const stylePath = path.join( + ROOT, + "rust/spikes/leptos-tiptap-spike/src/editor_runtime/style.rs", +); + +const blockMenu = fs.readFileSync(blockMenuPath, "utf8"); +const routes = fs.readFileSync(routesPath, "utf8"); +const style = fs.readFileSync(stylePath, "utf8"); + +assert.match( + blockMenu, + /mnote:toast/, + "复制链接必须触发全局可见 toast,而不是只改 command_feedback", +); +assert.match( + blockMenu, + /已复制块链接/, + "复制链接成功提示必须包含清晰的可见文案", +); + +["move", "history", "comment"].forEach((id) => { + assert.match( + blockMenu, + new RegExp('data-testid="block-' + id + '-panel"'), + id + " 入口必须打开可见面板,避免点击后仅 active 无反馈", + ); +}); + +assert.doesNotMatch( + blockMenu, + /移动\/嵌入流程后续|块历史入口已记录|评论入口已打开|颜色子菜单后续/, + "块菜单不能保留可点击但无真实 UI 的占位反馈", +); +assert.match(blockMenu, /data-testid="block-color-panel"/, "颜色入口必须打开真实颜色面板"); +assert.match(blockMenu, /apply_text_color\(editor, value\)/, "块菜单颜色面板必须能设置文字颜色"); +assert.match(blockMenu, /apply_highlight_color\(editor, Some\(value\)\)/, "块菜单颜色面板必须能设置背景色"); +assert.match( + blockMenu, + /data-testid="block-drag-menu-item-align-center"[\s\S]{0,240}selection_state\.get\(\)\.align_center/, + "文字居中菜单项必须显示当前居中状态", +); + +assert.match( + routes, + /\/api\/documents\/buffer-state\/dirty:0/, + "必须兼容旧浏览器误发的 /api/documents/buffer-state/dirty:0,避免控制台 404", +); + +assert.doesNotMatch( + style, + /@media \(max-width: 768px\)[\s\S]*?\.block-handle-shell,[\s\S]*?display: none;/, + "移动端不能隐藏块手柄;点击正文后必须保留可触达的块菜单入口", +); +assert.match( + style, + /@media \(max-width: 768px\)[\s\S]*?\.block-handle-trigger[\s\S]*?(?:width|height): (?:3[6-9]|4[0-9])px;/, + "移动端块手柄触控目标至少应为 36px", +); + +console.log("task805 editor block menu static smoke passed"); diff --git a/skills/mnote-knowledge-rag/SKILL.md b/skills/mnote-knowledge-rag/SKILL.md index 9240582b..82bea24e 100644 --- a/skills/mnote-knowledge-rag/SKILL.md +++ b/skills/mnote-knowledge-rag/SKILL.md @@ -1,24 +1,24 @@ # MNote knowledge RAG -> 2026-07-01 口径:当前默认知识库主线切到 RAGFlow。WeKnora 与 LightRAG 只作为 legacy fallback / 显式调试路径,不作为默认资料库问答路径。 +> 2026-07-19 口径:当前默认知识库主线是 **LightRAG**(runtime 默认 `lightrag_legacy` / `lightrag`)。WeKnora 与 RAGFlow 只作为 env 显式切换的备用 / 调试路径,不是普通资料库问答默认。 -使用场景:用户要求“资料库问答”、跨多本书/论文/PDF/Office 附件问答,或明确需要 RAGFlow 知识库结果。 +使用场景:用户要求“资料库问答”、跨多本书/论文/PDF/Office 附件问答,或需要可回跳 citation 的知识库结果。 工具使用: - 先用 `mnote.knowledge_rag.status` 检查当前知识库 provider 是否可用、当前 root 是否有 source registry。 - 用 `mnote.knowledge_rag.query` 提问。指定资料细节时可传 `sourcePaths`,但当前语义是 provider 检索后,MNote 只过滤返回的 `references`;不要把 `raw` 当作已被 scope 限制的来源。 -- 当前 RAGFlow provider 的 MNote 后端 query/search/ingest adapter 尚未完成时,先打开 `/knowledge` 使用 RAGFlow 原生页面验证上传、解析、检索、citation 与 `positions`。 -- 如果返回 `documentStructureIndex`,它只是一张章节地图。需要阅读某个章节范围时,调用 `mnote.knowledge_rag.section_context`,传 `sourcePath` 以及 `startBlockOrdinal/endBlockOrdinal` 或 `startParagraphOrdinal/endParagraphOrdinal`,并限制 `maxBlocks/maxChars`。 -- 回答必须引用 `references` 中的来源;优先使用返回的 `citationMarkdown`。 +- 对大书/长文档:query 时传 `includeDocumentStructureIndex=true`(或等价字段)。如果返回 `documentStructureIndex`,它只是一张章节地图;需要阅读某个章节范围时,再调用 `mnote.knowledge_rag.section_context`,传 `sourcePath` 以及 `startBlockOrdinal/endBlockOrdinal` 或 `startParagraphOrdinal/endParagraphOrdinal`,并限制 `maxBlocks/maxChars`。 +- 回答必须引用 `references` 中的来源;优先使用返回的 `citationMarkdown`。不要在最终回答中手写 `/documents`、`mnote://`、`file://` 或搜索引擎包装链接。 - 若需要打开来源,调用 `mnote.knowledge_rag.open_reference`,不要手拼 provider dashboard、`file://` 或 provider 内部路径。 -- 如果当前 Agent runtime 只有 MCP 入口,优先调用当前 RAGFlow MCP / provider-neutral bridge;WeKnora、`mnote_lightrag_bridge.*` 和 `lightrag_native.*` 只用于 legacy fallback 或显式调试,不作为普通问答首选。 -- 引入或变更 RAGFlow bridge、`mnote.knowledge_rag.*` 工具、citation 渲染或 open-reference 映射后,必须跑 Page AI 浏览器验收:AI 可见回答包含 citation 链接,点击该链接后 MNote 能打开对应资源并落到 locator。 +- 如果当前 Agent runtime 只有 MCP 入口,优先走 provider-neutral 的 `mnote.knowledge_rag.*` / MNote LightRAG bridge;`lightrag_native.*` 只在需要原生 graph/documents/pipeline 管理时使用,不要用它绕过 source registry 和 citation 映射。 +- 引入或变更 LightRAG bridge、`mnote.knowledge_rag.*` 工具、citation 渲染或 open-reference 映射后,必须跑 Page AI 浏览器验收:AI 可见回答包含 citation 链接,点击该链接后 MNote 能打开对应资源并落到 locator。 边界: - 知识库 provider 是派生缓存,不是 MNote Markdown 正文真相。 - `filePath` 是 provider-local 名称;只有 registry 命中的 reference 才能映射回 MNote source。 - 如果返回 `locatorDegraded: true`,可以使用返回的 `citationMarkdown`,但必须明说“来源定位降级”,不要伪造页码、bbox 或 provider 内部路径。 -- 当前 provider 不可用时,不要声称资料库已查到;提示用户启动或检查 RAGFlow provider。旧 WeKnora / LightRAG / LiteParse / evidence 检索已退役,不作为默认 fallback。 +- 当前 provider 不可用时,不要声称资料库已查到;提示用户启动或检查 LightRAG / 知识库服务。旧 WeKnora / RAGFlow / LiteParse / `mnote.evidence.*` 本地 evidence 检索不作为默认 fallback。 - MNote 的职责不是重排、补召回或再做一套全文搜索;MNote 只做 allowed roots、source registry、结果过滤、有限 section context 和 citation/open-reference 映射。 +- 兼容 skill 别名:`mnote-document-evidence` / `mnote-local-index` 只解析到本 skill,不恢复旧 evidence/index 工具面。 diff --git a/skills/mnote-lightrag-bridge/SKILL.md b/skills/mnote-lightrag-bridge/SKILL.md deleted file mode 100644 index 6c1d334a..00000000 --- a/skills/mnote-lightrag-bridge/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: mnote_lightrag_bridge -description: Use when an agent must query MNote's LightRAG-backed knowledge library through MNote source registry, or convert LightRAG references/file_path/chunk_id into clickable MNote locators. ---- - -# MNote LightRAG Bridge - -> 2026-06-28 口径:LightRAG bridge 已退为 legacy fallback / 历史调试工具。普通资料库问答默认走 WeKnora / OpenHub,不再优先使用本 skill。 - -用途:把 agent 的资料库查询稳定路由到 MNote `knowledge_rag` facade,并把引用映射成 MNote 可点击、可打开、可定位的 locator。 - -## 工具分层 - -- `mnote_lightrag_bridge.mnote_knowledge_rag_query`:首选资料库问答入口,调用 MNote `/api/knowledge-rag/query`,返回已映射的 references/citations。 -- `mnote_lightrag_bridge.mnote_knowledge_rag_section_context`:按 `documentStructureIndex` 中的 section range 拉取有限 sidecar blocks/chunks,用于大书/长文档二次阅读,不做召回或重排。 -- `mnote_lightrag_bridge.mnote_knowledge_rag_open_reference` / `open_mnote_reference`:把 `file_path/chunk_id` 映射为 `citationUrl`、`openAction`。 -- `lightrag_native`:只在需要 LightRAG 原生 graph/documents/pipeline 管理时使用;普通资料库问答优先走 MNote bridge,避免绕过 source registry 和 citation contract。 - -## 使用流程 - -1. 资料库问答直接调用 `mnote_lightrag_bridge.mnote_knowledge_rag_query`;大书/长文档问题传 `source_paths` 并设置 `include_document_structure_index=true`。 -2. 回答依据必须来自返回的 `references[]` / `citations[]`;`documentStructureIndex` 只作章节导航,不单独当证据。 -3. 需要阅读某章节时,调用 `mnote_knowledge_rag_section_context`,传 `source_path` 以及 `start_block_ordinal/end_block_ordinal` 或 `start_paragraph_ordinal/end_paragraph_ordinal`,并限制 `max_blocks/max_chars`。 -4. 如已有 LightRAG 原生 `file_path/chunk_id`,调用 `mnote_lightrag_bridge.open_mnote_reference` 或 `mnote_knowledge_rag_open_reference` 转成 MNote locator。 -5. 最终回答引用 bridge 返回的 `citationMarkdown` 或让 MNote UI 渲染 citation;不要手写 `/documents`、`file://`、LightRAG dashboard URL。 - -## 边界 - -- `file_path` 是 LightRAG provider 内部文件名,不等于用户本地相对路径。 -- 普通问答不要直接调用 `lightrag_native.query_*` 绕过 MNote;否则 citations 可能没有 source registry 和定位信息。 -- 只有 MNote registry 命中的 reference 才能映射回本地资源和页面。 -- `locatorDegraded=true` 时必须说明“来源定位降级”,不能伪造页码、bbox、段落或坐标。 -- 不要用 bridge 做召回、重排、全文 fallback 或资料管理。 diff --git a/skills/mnote-local-file/SKILL.md b/skills/mnote-local-file/SKILL.md index a00e69a8..7cd50e89 100644 --- a/skills/mnote-local-file/SKILL.md +++ b/skills/mnote-local-file/SKILL.md @@ -10,3 +10,4 @@ Rules: - After writing, read the file back with the agent's native file tools and report changed files. - Do not use MNote file tools for pure chat. - If authorization is missing, ask the user to grant access instead of guessing a path. +- **Password vault is forbidden:** never read, write, list, patch, or search `.mnote/vault/**` (entries, cipher-book, index, audit, trash, attachments) via local file tools or generic open/resource APIs. Credentials and cipher fragments go through the password vault UI / `mnote.vault.*` (see skill `mnote-vault`) and share-to-ai — not this skill. diff --git a/skills/mnote-local-index/SKILL.md b/skills/mnote-local-index/SKILL.md deleted file mode 100644 index 87b558f0..00000000 --- a/skills/mnote-local-index/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ -# MNote local index - -使用场景:需要检索本地工作区资料、引用可回跳证据,或让 agent 帮忙查看、新增、刷新、删除本地索引范围。 - -证据检索: - -- 先用 `mnote.evidence.search` 搜索资料证据,回答时保留返回的 `quote`、`source` 和 `openAction`。 -- `mnote.evidence.search` / `mnote.evidence.read` 会返回 `citationUrl`、`citationLabel`、`citationMarkdown`。最终回答引用时优先直接使用 `citationMarkdown`,格式如 `[文件名 · p.3](citationUrl)`;该链接在 MNote Page AI 中点击会在当前页面的资源新 tab 打开,并按 `page` / `bbox` / `blockId` / `sourceMapPath` 跳转或高亮到证据位置。 -- 如果用户查询语言与资料语言可能不一致,不要假定目标资料语言,也不要只把整句原文直接丢给 FTS。先保留用户意图,再根据文件名、题名、领域术语和已知上下文生成 2-6 个轻量多语关键词候选;不确定时优先同时尝试原语言关键词和常见英文术语。中文问法查生命科学文献时,例如“分子对接软件”可扩展为 `分子对接`, `molecular docking`, `docking software`, `AutoDock`, `Vina`。 -- 关键词扩展只是检索策略,不是结论。回答仍按用户语言组织,且只根据 evidence 结果确认事实。 -- 需要更多上下文时,用 `mnote.evidence.read` 按 locator 读回周边证据。 -- 需要让 UI 打开定位时,用 `mnote.evidence.open`,不要自行拼接 URL。 - -索引管理: - -- 用 `mnote.index.status` 查看当前 root 的 `settings`、`effectiveSettings`、索引文件是否存在、文档数和证据块数。 -- 用 `mnote.index.update_settings` 新增或删除索引范围。`includePaths` 是 root 内相对路径;已保存范围视为固化记录,不直接改目录。要改目录时,删除旧范围并新增新范围。 -- `includePaths: []` 表示删除当前用户的索引范围;当没有任何有效范围时,会清空 `.mnote/index/search-index.json` 和 `.mnote/index/evidence.sqlite`。 -- 用 `mnote.index.refresh` 重建当前有效范围的索引缓存。刷新只重建索引,不修改 Markdown 正文。 - -写入约束: - -- 调用 `mnote.index.update_settings` 必须显式携带 `dryRun` 和 `idempotencyKey`。 -- 在只读或共享只读 AI scope 下,不要调用 `mnote.index.update_settings`。 -- `dryRun: true` 只返回计划,不写设置、不重建索引。 - -回答要求: - -- 关键事实必须带可回跳来源,至少包含文档名、资源名或页码、quote 摘要。 -- 关键事实后必须给可点击引用链接;如果 `citationMarkdown` 缺失,先调用 `mnote.evidence.open` 取得 `citationUrl`,不要自己拼 `file://`、`.mnote/index` 或 OCR sidecar 路径。 -- 不要直接读取 `.mnote/index`、OCR sidecar 或 provider 原始响应来替代 evidence tool。 -- 不要把 OCR 文本当成 owner Markdown 正文真相。 -- 如果 evidence 结果没有 page、bbox 或 section,只说明定位能力降级,不伪造页码。 diff --git a/skills/mnote-vault/SKILL.md b/skills/mnote-vault/SKILL.md new file mode 100644 index 00000000..411ae6fd --- /dev/null +++ b/skills/mnote-vault/SKILL.md @@ -0,0 +1,53 @@ +--- +name: mnote-vault +description: > + MNote AI 密码本(多 agent 唯一策略)。登录优先 mnote.vault.login(复用/刷新 session); + 取密 mnote.vault.resolve;禁扫 .mnote/vault 文件。Cloudflare 时人机验证后 session 回写。 +--- + +# MNote 密码箱 / AI 密码本 + +**SSOT**:`/home/lix/.agent-infra/vault-policy.md` + +## 稳态:登录(目标约 3 次 shell:鉴权 + list? + login×2) + +``` +# 本机 mnote-web 鉴权(一次性,非 vault) +eval $(node scripts/mnote-vault-cli.js auth-e2e) + +# 可选 list 找 id;已知 id 则跳过 +node scripts/mnote-vault-cli.js list + +# 核心:复用 session / 登录并保存 +node scripts/mnote-vault-cli.js login --id +# 再调一次应 reused=true + +# Cloudflare:人验证后 +node scripts/mnote-vault-cli.js session --id --cookie '...' +``` + +Pi:`mnote.vault.login` / `session` 同语义。 + +**禁止**每次:读 policy、猜 /api/auth 路径、list+resolve+多次 chrome 试错。 + +| 工具 | 用途 | +|------|------| +| `mnote.vault.login` | **首选**:复用/刷新登录态 | +| `mnote.vault.session` | 人机验证后回写 Cookie | +| `mnote.vault.list` / `get` | 选型;L0 无密文 | +| `mnote.vault.resolve` | 仅当需要密码本身(非登录会话) | + +HTTP/CLI 同语义:`/api/vault/ai/items/{id}/login|session`、`scripts/mnote-vault-cli.js login|session`。 + +## 硬规则 + +1. 禁 file/local_file 扫 `.mnote/vault/**` +2. 聊天不贴 password/cookieHeader +3. 长期态存在 **AI 密码本条目**(session + playbook),不另建文件池 +4. 人机验证:本机 **chrome-bridge** / 远程 **Paseo 浏览器** → `session` 回写;过期再验证 + +## 工作流 + +1. 用户共享条目到 AI 本(自动带默认 playbook:email 优先 + api_first) +2. Agent:`login(id)` +3. 用返回 cookie 访问站点;或 human_required 后等人回写再 `login`