From 262e66b02e6ede6f5c45c70e8bceb50191b599c6 Mon Sep 17 00:00:00 2001 From: Agent Board Date: Sat, 25 Jul 2026 14:25:37 +0800 Subject: [PATCH] feat: purge legacy agent hosts and land vault Chrome extension path Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault extension + extension token route, pre-release purge design, and soft-retire legacy smokes for the small-group production cut. --- AGENTS.md | 114 +- ARCHITECTURE.md | 170 +- .../process/22-pre-release-legacy-purge-v1.md | 222 + ...sword-vault-dedicated-crud-workbench-v1.md | 7 +- ...2-vaultd-local-token-agent-read-path-v1.md | 3 + .../12-3-chrome-extension-vault-save-v1.md | 929 + extensions/mnote-vault/README.md | 96 + extensions/mnote-vault/background.js | 1236 + extensions/mnote-vault/content.js | 655 + extensions/mnote-vault/icons/icon128.png | Bin 0 -> 343 bytes extensions/mnote-vault/icons/icon16.png | Bin 0 -> 88 bytes extensions/mnote-vault/icons/icon48.png | Bin 0 -> 134 bytes extensions/mnote-vault/lib/api.js | 416 + extensions/mnote-vault/lib/storage.js | 291 + extensions/mnote-vault/manifest.json | 49 + extensions/mnote-vault/options.html | 145 + extensions/mnote-vault/options.js | 171 + extensions/mnote-vault/popup.html | 45 + extensions/mnote-vault/popup.js | 80 + rust/Cargo.lock | 3 + rust/crates/mnote-vault-core/src/ai.rs | 15 +- rust/crates/mnote-vault-core/src/store.rs | 789 +- rust/crates/mnote-web/Cargo.toml | 5 +- .../browser/agent-stream-event-router.js | 127 - .../browser/document-session-runtime.js | 6 +- .../sidebar-page-ai-markdown-runtime.js | 359 - .../sidebar-page-ai-permission-runtime.js | 344 - .../sidebar-page-ai-profile-runtime.js | 281 - .../browser/sidebar-page-ai-render-runtime.js | 1878 -- .../browser/sidebar-page-ai-runtime.js | 5385 +---- .../sidebar-page-ai-session-runtime.js | 863 - .../browser/sidebar-page-ai-skill-runtime.js | 310 - .../browser/sidebar-page-ai-target-runtime.js | 773 - .../browser/sidebar-page-settings-runtime.js | 169 +- .../mnote-web/browser/sidebar-tree-runtime.js | 62 +- .../browser/vault-workbench-runtime.js | 468 +- rust/crates/mnote-web/src/acp_bridge.rs | 621 - rust/crates/mnote-web/src/acp_client.rs | 755 - rust/crates/mnote-web/src/acp_runtime.rs | 391 - .../mnote-web/src/acp_session_manager.rs | 1858 -- rust/crates/mnote-web/src/acp_types.rs | 802 - rust/crates/mnote-web/src/api_chat.rs | 368 - rust/crates/mnote-web/src/app.rs | 7 - rust/crates/mnote-web/src/context.rs | 2 +- .../mnote-web/src/document_buffer_store.rs | 2 +- rust/crates/mnote-web/src/lib.rs | 10 +- .../src/middleware/request_context.rs | 21 +- .../artifact.rs | 12 +- .../block.rs | 32 +- .../context_tools.rs | 2 +- .../doc.rs | 14 +- .../index.rs | 2 +- .../knowledge_rag.rs | 2 +- .../manifest.rs | 4 +- .../mod.rs | 10 +- .../onlyoffice_live.rs | 2 +- .../page.rs | 14 +- .../resource.rs | 4 +- .../skill.rs | 62 +- .../mnote-web/src/routes/ai_settings.rs | 4 +- rust/crates/mnote-web/src/routes/bridge.rs | 1 - rust/crates/mnote-web/src/routes/compat.rs | 10 +- rust/crates/mnote-web/src/routes/dev_seed.rs | 7 +- rust/crates/mnote-web/src/routes/documents.rs | 1 - rust/crates/mnote-web/src/routes/editor.rs | 1 - rust/crates/mnote-web/src/routes/evidence.rs | 1 - rust/crates/mnote-web/src/routes/gateway.rs | 14 +- rust/crates/mnote-web/src/routes/hermes.rs | 297 - .../mnote-web/src/routes/hermes_client.rs | 18684 ---------------- rust/crates/mnote-web/src/routes/kernel.rs | 1 - .../src/routes/local_folder_events.rs | 1 - .../src/routes/local_folder_source.rs | 62 +- .../src/routes/local_search_index.rs | 6 +- .../mnote-web/src/routes/mindmap_api.rs | 1 - .../mnote-web/src/routes/mindmap_shell.rs | 4 +- .../{hermes_tools.rs => mnote_tools.rs} | 642 +- rust/crates/mnote-web/src/routes/mod.rs | 358 +- .../mnote-web/src/routes/navigation_recent.rs | 1 - .../crates/mnote-web/src/routes/onlyoffice.rs | 1 - .../mnote-web/src/routes/page_ai_board.rs | 334 - .../mnote-web/src/routes/page_ai_opencode.rs | 1143 - .../src/routes/page_ai_pi/runtime.rs | 3 +- .../mnote-web/src/routes/page_ai_workflow.rs | 69 +- .../mnote-web/src/routes/resource_trash.rs | 1 - rust/crates/mnote-web/src/routes/search.rs | 17 +- rust/crates/mnote-web/src/routes/session.rs | 6 +- rust/crates/mnote-web/src/routes/sse.rs | 1 - rust/crates/mnote-web/src/routes/tree.rs | 3 - rust/crates/mnote-web/src/routes/vault.rs | 269 +- .../src/routes/vault_extension_token.rs | 466 + .../mnote-web/src/routes/vault_store.rs | 881 +- rust/crates/mnote-web/src/routes/web_shell.rs | 188 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 288 +- rust/crates/mnote-web/src/ssr/styles.rs | 3 +- .../src/ssr/styles/components/page-ai.css | 326 - .../src/ssr/styles/components/vault.css | 146 + scripts/desktop-hot.js | 81 +- scripts/desktop-hot.test.js | 22 +- scripts/dev-hot.js | 3 - scripts/reasonix-acp-wrapper.mjs | 1262 -- scripts/task-block-delta-smoke.js | 4 +- scripts/task-dev-hot-plan-test.js | 2 +- scripts/task-editor-delta-channel-smoke.js | 2 +- scripts/task-editor-runtime-actor-smoke.js | 2 +- ...age-aggregate-refresh-persistence-smoke.js | 2 +- ...task-page-ai-apply-block-ops-real-smoke.js | 245 +- .../task-page-ai-block-edit-workflow-smoke.js | 209 +- ...age-block-ai-conflict-idempotency-smoke.js | 4 +- ...task-page-block-ai-context-format-smoke.js | 6 +- scripts/task-page-block-ai-tools-smoke.js | 2 +- scripts/task-pi-lab-static-smoke.js | 24 +- scripts/task116-rust-web-islands-smoke.js | 61 +- ...st-web-ai-bridge-structured-write-smoke.js | 54 +- ...own-open-document-external-change-smoke.js | 2 +- ...ocal-folder-page-ai-changed-files-smoke.js | 493 +- ...sk454-shared-read-page-ai-browser-smoke.js | 198 +- scripts/task490-runtime-surfaces-smoke.js | 390 +- ...02-page-ai-agent-selector-context-smoke.js | 925 +- .../task503-mindmap-skill-capability-smoke.js | 2 +- ...k504-page-ai-history-agent-filter-smoke.js | 606 +- scripts/task512-chatonly-doubao-sync-smoke.js | 484 +- .../task513-chatonly-provider-sync-smoke.js | 497 +- ...ask515-onlyoffice-live-scope-http-smoke.js | 14 +- ...yoffice-real-iframe-session-scope-smoke.js | 2 +- ...sk520-page-ai-raw-resource-target-smoke.js | 328 +- ...ai-onlyoffice-real-target-session-smoke.js | 433 +- ...5-page-ai-mindmap-resource-target-smoke.js | 316 +- .../task527-chatonly-api-provider-smoke.js | 352 +- ...nowledge-rag-page-ai-final-answer-smoke.js | 409 +- ...35-page-ai-local-agent-clean-edit-smoke.js | 391 +- ...6-page-ai-local-agent-dirty-guard-smoke.js | 319 +- ...-local-agent-readonly-write-guard-smoke.js | 267 +- .../task557-page-ai-agent-descriptor-smoke.js | 178 +- .../task763-page-ai-opencode-embed-smoke.js | 308 +- ...k765-page-ai-opencode-host-static-smoke.js | 44 +- scripts/vault-extension-api-smoke.js | 327 + skills/mnote-vault/SKILL.md | 93 +- 137 files changed, 9018 insertions(+), 46049 deletions(-) create mode 100644 design/10-review/process/22-pre-release-legacy-purge-v1.md create mode 100644 design/12-vault/process/12-3-chrome-extension-vault-save-v1.md create mode 100644 extensions/mnote-vault/README.md create mode 100644 extensions/mnote-vault/background.js create mode 100644 extensions/mnote-vault/content.js create mode 100644 extensions/mnote-vault/icons/icon128.png create mode 100644 extensions/mnote-vault/icons/icon16.png create mode 100644 extensions/mnote-vault/icons/icon48.png create mode 100644 extensions/mnote-vault/lib/api.js create mode 100644 extensions/mnote-vault/lib/storage.js create mode 100644 extensions/mnote-vault/manifest.json create mode 100644 extensions/mnote-vault/options.html create mode 100644 extensions/mnote-vault/options.js create mode 100644 extensions/mnote-vault/popup.html create mode 100644 extensions/mnote-vault/popup.js delete mode 100644 rust/crates/mnote-web/browser/agent-stream-event-router.js delete mode 100644 rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js delete mode 100644 rust/crates/mnote-web/browser/sidebar-page-ai-permission-runtime.js delete mode 100644 rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js delete mode 100644 rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js delete mode 100644 rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js delete mode 100644 rust/crates/mnote-web/browser/sidebar-page-ai-skill-runtime.js delete mode 100644 rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js delete mode 100644 rust/crates/mnote-web/src/acp_bridge.rs delete mode 100644 rust/crates/mnote-web/src/acp_client.rs delete mode 100644 rust/crates/mnote-web/src/acp_runtime.rs delete mode 100644 rust/crates/mnote-web/src/acp_session_manager.rs delete mode 100644 rust/crates/mnote-web/src/acp_types.rs delete mode 100644 rust/crates/mnote-web/src/api_chat.rs rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/artifact.rs (96%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/block.rs (98%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/context_tools.rs (98%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/doc.rs (99%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/index.rs (99%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/knowledge_rag.rs (99%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/manifest.rs (99%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/mod.rs (97%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/onlyoffice_live.rs (99%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/page.rs (97%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/resource.rs (99%) rename rust/crates/mnote-web/src/{hermes_tools => mnote_agent_tools}/skill.rs (90%) delete mode 100644 rust/crates/mnote-web/src/routes/hermes.rs delete mode 100644 rust/crates/mnote-web/src/routes/hermes_client.rs rename rust/crates/mnote-web/src/routes/{hermes_tools.rs => mnote_tools.rs} (92%) delete mode 100644 rust/crates/mnote-web/src/routes/page_ai_board.rs delete mode 100644 rust/crates/mnote-web/src/routes/page_ai_opencode.rs create mode 100644 rust/crates/mnote-web/src/routes/vault_extension_token.rs delete mode 100644 scripts/reasonix-acp-wrapper.mjs create mode 100644 scripts/vault-extension-api-smoke.js diff --git a/AGENTS.md b/AGENTS.md index 7a993ec1..9fe4c511 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,13 +2,28 @@ ## 当前主线 -- 方向:`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`。 +- 产品:`tree-first graph kernel` + local-first 工作区;云首发同栈(单机/云服务器均可部署)。 +- 形态:`VSCode 简化版工作区 + leptos-tiptap Markdown + Pi Lab Page AI + LightRAG + mindmap/OnlyOffice + Wolai 主题壳 + libSQL 控制面 + Vault`。 +- 数据真相:本地(或服务器上的)workspace folder;页面正文为 `.md`。 +- 控制面:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` 为默认与云首发推荐(独立部署、无需 Turso 云账号);`turso-remote` / `turso-local-replica` 仅在明确需要托管同步时使用。`sqlite` 不是 mnote-web 运行时。 +- Web:`mnote-web` 为 **3000** 唯一公开入口。kernel 持有树/边/projection/command;前端只消费稳定 projection。 +- Page AI:**Pi Lab**(`/api/page-ai/pi/*`)为唯一产品入口。 +- 知识库:LightRAG + `mnote.knowledge_rag.*` / `/api/knowledge-rag/*`。 +- 正文保存:`page.body.write` + 文件版本;AI 写文件:授权 scope + allowed roots + Pi patch/diff + watcher。 +- 架构 SSOT:`ARCHITECTURE.md`。预发瘦身清单:`design/10-review/process/22-pre-release-legacy-purge-v1.md`。产品化收口:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。 + +## 云首发范围(必须保留) + +| 能力 | 入口 / 说明 | +|------|-------------| +| 工作区 + 树 + 文档 | kernel + FileTree/PageTree + leptos-tiptap | +| Page AI | Pi Lab | +| 知识库 | LightRAG | +| 办公文档 | OnlyOffice(附件/资源进入) | +| 密码箱 | Vault + `$mnote-vault` | +| 控制面 | `libsql-local`(auth / membership / share / AI policy / Pi scope) | + +不在首发产品面、且不得在文档中当作可选主链宣传的路径:旧 agent 网关、第三方 ACP 宿主、OpenCode 代理壳、debug tree shell(默认关闭)。清理顺序与可删文件见 purge 清单。 ## 组件定位 @@ -18,17 +33,17 @@ | Mindmap | tree-first 视图/挂件;非真相层 | | OnlyOffice | 独立页面编辑器;经附件/资源进入 | | LightRAG + Pi | 知识库与 Page AI 默认融合 | +| Vault | 本机/服务器密码箱;AI 经 capability token 读密 | | Resource → File → Page Tree | 对象真源 → 主组织投影 → 导航投影 | ## Runtime 模块定位 - 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`) + - Sidebar:`sidebar-tree-runtime.js`(及 workspace / page tree / filetree / upload 等) + - FileTree:`filetree-*.js` + - Page AI host:`sidebar-page-ai-*.js`(默认 Pi Lab) - 文档 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 / 薄壳) +- Island:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/` - SSR 注入:`layout.rs` / `tree.rs` / `web_shell.rs` — 非大型 JS 功能定位入口 ## 目录优先级 @@ -37,84 +52,71 @@ |------|------| | `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/` | 3000 route、transport、projection | | `rust/crates/mnote-web/browser/` | 浏览器 runtime JS | | `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island | -| `wolai-backend/app/` | 辅助后端 / 异步 | +| `rust/crates/mnote-vault-core/` | Vault 核心 | | `src/components/onlyoffice/` | OnlyOffice 静态与插件 | -| `recycle/` | 历史回收;默认不作为实现依据 | +| `wolai-backend/app/` | 辅助后端 / 异步(非 3000 主入口) | +| `recycle/` | 回收区;不作为实现依据 | +| `reference-code/` | 对照参考;**不进部署包** | ## 架构约束 -- 树 / 页面结构 / 引用边:优先落 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 稿。 +- 树 / 页面结构 / 引用边:落 Rust kernel,禁止 UI 拼第二套真相。 +- 浏览器主链禁止新增 `setInterval` / 周期轮询;用 WS/SSE、watcher、command result、`MutationObserver`。 +- 标题 / 页面设置 / 正文:收口 Page Aggregate。 +- 新建/重命名/移动/归档:`tree.*`。 +- AI 正文:文件引用 + `AiAccessScope` + Pi patch/diff + 版本冲突 + watcher。Phase C 流式 apply 冻结。 +- Page AI 宿主为原生 Pi Lab runtime;普通 UI 不得出现第二套 agent 切换入口。 +- Agent 工具包:`rust/crates/mnote-web/src/mnote_agent_tools/`(HTTP 面 `/api/mnote/tools/*`;Pi / LightRAG / skill / OnlyOffice live tools 依赖,**不可整目录误删**)。 +- 架构判断:`ARCHITECTURE.md` → 对应 `design/**` process/done 稿。 ## 设计稿目录规则 -- `design/01-*` … `design/10-*`:`process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。 -- 已完成主线稿必须迁入 `done/`;被覆盖且非 done 的旧稿迁 `design/old/` 并标 `[recycle]`。 +- `design/01-*` … `design/12-*`:`process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。 +- 已完成主线稿迁 `done/`;被覆盖旧稿迁 `design/old/` 并标 `[recycle]`。 - `design/90-reference/` 只放参考资料。 ## Bugs 目录规则 - 镜像 design 大类;`process/` 未修完,`done/` 已验证。 -- 按真正 owner 归类:壳/Sidebar/文档页体验 → `05-editor-mainline/`;projection/selection/DnD/tree command → `04-tree-domain/`。 -- MVP 阶段发现 bug 时判断是否系统缺口;边界不清先问用户。 +- 壳/Sidebar/文档页体验 → `05-editor-mainline/`;projection/selection/DnD/tree → `04-tree-domain/`。 ## 协作边界 - 只改与任务直接相关的文件;不覆盖用户已有改动;无关脏改动保持不动。 -- UI 异常先查 debug shell / compat / 轮询是否混入主链。 -- 发现轮询先评估事件驱动替代,再决定是否保留临时 fallback。 -- Subagent 为受控叶子:任务书限定读写范围、验证命令、交付与禁止项;不得再启 runner/worktree 或改任务书。主控复核 diff/证据后才采纳。 -- 同一写入范围同时只允许一个 writer(主控或一个 worker);写入范围失效须先取消 worker 再改。 -- 最终测试/回复前确认实现型 subagent 已完成、已取消,或输出已标不采纳。 +- Subagent 为受控叶子:限定读写范围与验证命令;主控复核 diff/证据后才采纳。 +- 同一写入范围同时只允许一个 writer。 ## CodeGraph -- 结构性代码问题优先 CodeGraph MCP(search / callers / callees / impact / explore)。 +- 结构性代码问题优先 CodeGraph MCP(explore / impact)。 - 改代码后 `codegraph sync .`;大重构或索引异常 `codegraph index . --force`。 -- 提交前再 sync,确认无 pending;字面文本/日志字符串才优先用 `rg`。 +- 字面文本/日志字符串优先 `rg`。 ## Reference-Code -- 优先 `reference-code/sidex-main`(完整 VSCode 工作台);`reference-code/vscode` 为裁剪辅助。 -- 每次只对照一个切面;区分可直接采用的交互模型 vs 不符合 local-first/tree-first 的细节。 -- 不要把参考项目 UI 状态当成 MNote 新事实源。 +- 优先 `reference-code/sidex-main`;`reference-code/vscode` 为裁剪辅助。 +- 每次只对照一个切面;不要把参考项目 UI 状态当成 MNote 新事实源。 ## 常用命令 - 热启动:`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 运行时。 +- Control-plane 默认:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` +- 云首发:同 `libsql-local` + 服务器本地数据目录;需要托管再换 `turso-remote` ## Smoke 与测试账号 -- 基线:`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。 +- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + libSQL auth`。 +- 禁止 smoke 用 `sqlite3` CLI 直写 control-plane;seed 走 Rust API / `scripts/lib/control-plane-dev-seed.js`。 +- 测试账号:`mnote.e2e@example.com` / `MnoteE2E123!` / `mnote-e2e`;优先 `/auth`「测试账号快速登录」。 ## 前端测试 -- 看真实渲染:`/doko`;交互回归:浏览器自动化。 +- 真实渲染:`/doko`;交互回归:浏览器自动化。 - 主页/Sidebar/文档首屏优先复用 `scripts/task*-smoke.js`。 -- 高 CPU/内存:先查 debug shell、compat fallback、重复请求、轮询。 - -## Wolai-aline - -- 启用 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`;滑块验证不绕过,记录阻塞。 ## 编码 @@ -122,6 +124,6 @@ ## 密码箱 / agent skills(薄指针) -- 密码箱策略 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 注入短段。 +- 策略 SSOT:`/home/lix/.agent-infra/vault-policy.md`;skill `$mnote-vault`(`skills/mnote-vault`)。 +- Page AI pack 注册:agent tool pack 内 `skill` 模块 id `mnote-vault`(仅放 `skills/` 不够)。 +- 勿在本文件复制完整 vault 策略。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 59478a0d..35b053fd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,113 +1,116 @@ # MNote 架构 -> 更新时间:2026-07-19 -> 本文只描述**当前**产品形态、运行分层与收口缺口。历史迁移、退役路径与 changelog 不在此展开;协作规则见 `AGENTS.md`,执行 checklist 见 `design/`。 +> 更新时间:2026-07-25 +> 本文只描述**当前**产品形态与运行分层。协作规则见 `AGENTS.md`;预发瘦身见 `design/10-review/process/22-pre-release-legacy-purge-v1.md`;产品化收口见 `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。 ## 1. 产品形态 ```text MNote = VSCode 简化版工作区 + tiptap Markdown 编辑器(leptos-tiptap island) - + Pi Rust Page AI + + Pi Lab Page AI + LightRAG 知识库 - + simplemindmap / OnlyOffice 插件 + + simplemindmap / OnlyOffice + + Vault 密码箱 + Wolai 主题 Web 壳 - + Turso/libSQL 鉴权控制面 + + libSQL 控制面(默认 libsql-local) ``` | 层 | 当前真源 / Owner | |----|------------------| -| 工作区数据 | 本地 workspace folder;页面正文为 `.md` | +| 工作区数据 | 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 为显示层,非对象真源 | +| 控制面 | libSQL/Turso store:auth、membership、share、sync state、AI policy / Pi scope | +| Page AI | Pi Lab(`/api/page-ai/pi/*`) | +| 知识库 | LightRAG + `mnote.knowledge_rag.*` | +| 密码箱 | Vault(store + web routes + extension/agent token) | +| 编辑投影 | Page Aggregate(`mnote.page_aggregate.v1`);tiptap 为显示层 | 一句话: -> **本地文件夹是数据真相,Rust kernel 持有语义,Turso/libSQL 持有控制面,前端只消费稳定 projection。** +> **工作区文件夹是数据真相,Rust kernel 持有语义,libSQL 持有控制面,前端只消费稳定 projection。** + +### 1.1 云首发 + +- 与本地同栈:Pi Lab、LightRAG、OnlyOffice、Vault、树/文档主链。 +- 控制面推荐 **`libsql-local`**:服务器上独立文件库,不依赖外部 Turso 账号;运维简单、可备份导出。 +- `turso-remote` / replica:可选增强,不是首发前置条件。 +- 部署包不含 `reference-code/`、`recycle/`、debug shell 默认路由。 ## 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 +- 默认 source:`local_folder`(本机或服务器磁盘路径) +- 管理员通过控制面授权可读写目录 +- 页面内上传默认 sibling assets;正文存相对 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 - -树域三层: +- `bridge-runtime` + `mnote-web`:LocalFS → 稳定 projection / command +- 前端消费:`file_tree`、`page_tree`、`page_aggregate`、tree command result | 层 | 角色 | |----|------| -| Resource Tree | kernel 对象组织真源(page / mindmap / attachment / onlyoffice / …) | -| File Tree | 主组织投影;页面正文行为 `{title}.md` | +| Resource Tree | kernel 对象组织真源 | +| File Tree | 主组织投影;页面正文 `{title}.md` | | Page Tree | 导航投影;不持有结构真相 | -正式命令面:`tree.*`(如 `tree.node.create` / `rename` / `tree.subtree.move`)。`documents.*` 仅为兼容层。 +正式命令面:`tree.*`。 ### 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:独立页面型编辑器,经附件/资源跳转进入 +- 文档页:`leptos-tiptap` island + `browser/*.js` +- 正文保存:`page.body.write` / `/api/page-body/write`(文件版本) +- Watcher:clean 自动刷新;dirty 冲突态,不静默覆盖 +- Realtime:WS `/api/realtime/ws` + SSE `/api/tree/events` +- Mindmap:视图/挂件;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 +- 读取:`mnote.page_aggregate.v1`(`/api/page-aggregate/:id`) +- 本地正文真相是 `.md`;aggregate 是投影 -### 2.5 Page AI(Pi Rust) +### 2.5 Page AI(Pi Lab) ```text 当前页 → 真实 .md → AiAccessScope / allowed roots / selection - → Pi Rust Page AI(/api/page-ai/pi/*) - → agent 原生 patch/diff 写文件 - → watcher / BufferStore / refresh → Page Aggregate + tiptap + → Pi Lab(/api/page-ai/pi/*) + → agent patch/diff 写文件 + → watcher / BufferStore → Page Aggregate + tiptap ``` -- 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 契约按实际消费者保留) +- Session / run / tool event 默认落 control-plane;Pi JSONL 为 runtime 工作副本 +- Agent 工具 HTTP:`/api/mnote/tools/{manifest,call,audit}` +- 工具实现目录:`rust/crates/mnote-web/src/mnote_agent_tools/` +- Pi 直接依赖其中 `knowledge_rag` 的 agent 输出整形,以及 skill pack(含 `mnote-vault`) +- 流式 apply + suggest/review:Phase C 冻结 ### 2.6 Knowledge RAG(LightRAG) -- 资料 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 为第二套索引 +- ingestion / query / citation / open-reference 主路径:LightRAG +- MNote 维护 source registry、权限、FileTree 灯号、status UI、citation 映射、tool facade +- Agent 工具:`mnote.knowledge_rag.status` / `query` / `section_context` / `open_reference` -### 2.7 Control-plane +### 2.7 Vault + +- 服务端 store + `/api/vault/*` 工作台;extension/agent 经 capability token +- 策略与 agent 用法:`/home/lix/.agent-infra/vault-policy.md`、`$mnote-vault` + +### 2.8 Control-plane ```text ControlPlaneStore - ├── TursoControlPlaneStore mnote-web 唯一运行时 - │ ├── libsql-local 默认 - │ ├── turso-remote - │ ├── turso-local-replica - │ └── turso-synced - └── SqliteControlPlaneStore 仅 admin 迁移/导出与测试隔离 + └── TursoControlPlaneStore mnote-web 唯一运行时 + ├── libsql-local 默认 / 云首发推荐 + ├── turso-remote 可选托管 + ├── turso-local-replica + └── turso-synced ``` | 变量 | 用途 | 默认 | @@ -116,59 +119,52 @@ ControlPlaneStore | `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 间隔 | 未设置 | - 只存元数据: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 运行 +- `sqlite` 不得作为 mnote-web runtime +- 备份:admin export → 再导入 libSQL;不回退 SQLite 运行 -### 2.8 代码入口 +### 2.9 代码入口 | 路径 | 职责 | |------|------| | `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/` 为历史回收区,不作为当前实现依据。 +| `rust/crates/mnote-web/` | 3000 gateway、SSR、API、realtime | +| `rust/crates/mnote-web/browser/` | Sidebar / FileTree / document / Page AI host | +| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island | +| `rust/crates/mnote-vault-core/` | Vault 核心 | +| `src/components/onlyoffice/` | OnlyOffice 静态资源 | ## 3. 当前收口缺口 -阶段:local-first **MVP 后**。底座与主路径已立;重点是统一与产品化,不是扩 compat 或旧主链。 +阶段:local-first **MVP 后**;重点是统一与产品化,以及预发前去掉非首发代码面。 -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;控制面不可用时不扩权限 +1. **WorkspacePath / ObjectIdentity** — 路径与 identity 统一到 kernel +2. **DocumentBuffer / BufferStore** — 多 tab 仲裁与冲突 UI +3. **Page Aggregate** — 以 Rust snapshot 为单一运行时真相 +4. **tree command context** — 菜单 / 快捷键 / AI enablement 同一 context +5. **tree live cache** — watcher + WS/SSE 收进同一 cache +6. **管理员目录授权 UI** +7. **冲突合并 UI** +8. **agent 写入审计** +9. **本地轻量搜索** +10. **分享与同步闭环** 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` +12. **预发瘦身** — 删除非 Pi 的 agent 宿主与巨型兼容网关;工具包重命名;见 `22-pre-release-legacy-purge-v1.md` ## 4. 设计索引(当前) | 主题 | 文档 | |------|------| +| 预发瘦身 | `design/10-review/process/22-pre-release-legacy-purge-v1.md` | | 总收口 | `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` | +| control-plane | `design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md` | +| Page Aggregate | `design/05-editor-mainline/` | | 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` | +| Vault | `design/12-vault/` | --- diff --git a/design/10-review/process/22-pre-release-legacy-purge-v1.md b/design/10-review/process/22-pre-release-legacy-purge-v1.md new file mode 100644 index 00000000..b19effa9 --- /dev/null +++ b/design/10-review/process/22-pre-release-legacy-purge-v1.md @@ -0,0 +1,222 @@ +# 22 · 预发 Legacy 瘦身清单 v1 + +> 状态:**process → 执行完成(Wave 0–9 出口已达)** +> 日期:2026-07-25 +> 目标:云首发前去掉非产品主链代码与叙事,减小二进制/认知负担;**不误伤 Pi Lab / LightRAG / OnlyOffice / Vault**。 +> 控制面云首发:`libsql-local`(独立部署)。 +> 产品 Page AI 唯一主链:**Pi Lab(Rust host `page_ai_pi` + `packages/pi-mnote`)**。 +> 关联:`AGENTS.md`、`ARCHITECTURE.md`、`21-mvp-post-architecture-closure-checklist-v1.md` + +## 0. 原则 + +1. **产品面只保留**:树/文档、Pi Lab、LightRAG、OnlyOffice、Vault、libSQL 控制面。 +2. **文档不再写**「OpenHub / Hermes / ACP / Reasonix / OpenCode 是兼容选项」类历史叙事;清理任务只写在本清单。 +3. **原子删除**:route + `lib.rs`/`mod.rs` 注册 + browser 引用 + 测试 fixture 同批;禁止半删导致编译红。 +4. **先重命名再删依赖名**:`hermes_tools` 是 **Pi 仍在用的 agent tool pack**,不是整包可删垃圾。 +5. **不动**用户进行中的 Vault WIP、无关脏改动。 +6. 影响面用 **CodeGraph** + `rg`;大块删除后 `cargo test -p mnote-web` + 主链 smoke。 + +## 1. 体量快照(2026-07-25 本机) + +| 路径 | 约行数 / 体积 | 处置 | +|------|----------------|------| +| `routes/hermes_client.rs` | ~18.7k 行 / ~688KB | **DELETED** | +| `routes/hermes_tools.rs` | ~6.8k 行 / ~272KB | **RENAMED** → `routes/mnote_tools.rs` | +| `src/hermes_tools/` | ~412KB(tool pack) | **RENAMED** → `src/mnote_agent_tools/` | +| `routes/hermes.rs` | ~297 行 | **DELETED** | +| `acp_*.rs`(5 文件) | ~4.4k 行合计 | **DELETED** | +| `routes/page_ai_opencode.rs` | ~1.1k 行 | **DELETED** | +| `routes/page_ai_pi/` | 主链 | **KEEP** | +| `routes/knowledge_rag.rs` | 主链 | **KEEP** | +| `routes/vault*.rs` | 主链 | **KEEP**(含进行中 extension token) | +| `reference-code/` | ~5GB 级 | **不进部署**;仓库可保留对照 | +| `recycle/` | 历史 | **不进部署** | + +## 2. 路由面 + +### 2.1 正式保留(产品) + +| 前缀 / 路径 | 说明 | +|-------------|------| +| `/api/page-ai/pi/*` | Pi Lab | +| `/api/mnote/tools/{manifest,call,audit}` | Agent 工具(中性名) | +| `/api/knowledge-rag/*` | LightRAG | +| `/api/vault/*` | Vault | +| `/api/page-body/*`、`/api/page-aggregate/*`、tree/realtime 等 | 文档与树主链 | +| `/api/onlyoffice/*` | OnlyOffice | + +### 2.2 已删除(非首发) + +| 前缀 / 路径 | 实现 | 状态 | +|-------------|------|------| +| `/api/hermes/*`(含 client/sessions/runs/…) | `hermes.rs` + `hermes_client.rs` | **DELETED** | +| `/api/hermes/tools/*` | legacy alias | **DELETED**(仅留 `/api/mnote/tools`) | +| `/api/page-ai/opencode/*`、`/page-ai/opencode` | `page_ai_opencode.rs` | **DELETED** | +| hermes_client 独占 page-ai sessions/runs | `hermes_client` | **DELETED**(Pi 自有 session API 保留) | +| debug:`/tree`、`/document-debug`、`/ui-debug/*` | flag `enable_debug_shell_routes` | 云默认 **false**;代码可保留 | + +### 2.3 配置 / 状态字段 + +| 符号 | 处置 | +|------|------| +| `AppConfig.hermes_base_path` | 已随 hermes 删除 / 测试清理 | +| `AppState.acp_runtime` | **DELETED**(随 ACP) | +| `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` | 云默认 false | +| `MNOTE_PAGE_AI_PI_LAB` | **保留**,云默认 true | +| `MNOTE_WEB_ACP_*` / `MNOTE_WEB_HERMES_*` / Reasonix wrapper | **DELETED** | + +## 3. 源码文件处置 + +### 3.1 DELETED(整文件) + +```text +rust/crates/mnote-web/src/routes/hermes_client.rs +rust/crates/mnote-web/src/routes/hermes.rs +rust/crates/mnote-web/src/routes/page_ai_opencode.rs +rust/crates/mnote-web/src/acp_bridge.rs +rust/crates/mnote-web/src/acp_client.rs +rust/crates/mnote-web/src/acp_runtime.rs +rust/crates/mnote-web/src/acp_session_manager.rs +rust/crates/mnote-web/src/acp_types.rs +scripts/reasonix-acp-wrapper.mjs +``` + +同步已改:`lib.rs`、`routes/mod.rs`、`app.rs`、相关测试 fixture。 + +### 3.2 RENAMED(不可整删) + +| 现状 | 目标 | 状态 | +|------|------|------| +| `src/hermes_tools/` | `src/mnote_agent_tools/` | **DONE** | +| `routes/hermes_tools.rs` | `routes/mnote_tools.rs` | **DONE** | +| `crate::hermes_tools::` | `crate::mnote_agent_tools::` | **DONE** | + +Pi 硬依赖(保留): + +- `page_ai_pi/runtime.rs` → `mnote_agent_tools::knowledge_rag` +- `page_ai_workflow.rs` → `ToolCallInput` + `execute_mnote_tool_call` +- skill pack:`mnote_agent_tools/skill.rs`(`mnote-vault` 等) + +### 3.3 KEEP(首发) + +```text +routes/page_ai_pi/ +routes/knowledge_rag.rs +routes/vault.rs / vault_store.rs / vault_extension_token.rs / … +routes/onlyoffice*.rs +browser/sidebar-page-ai-pi-lab-runtime.js 及 Pi 相关 host +packages/pi-mnote/ (若部署需要) +``` + +### 3.4 Browser 清理(产品入口) + +**产品唯一入口:Pi Lab** + +| 路径 | 行为 | +|------|------| +| 浮钮 `data-mnote-action="open-page-ai-pi-lab"` | 只打开 Pi Lab | +| `sidebar-tree-runtime` `open-page-ai` / `open-page-ai-pi-lab` | `mnote:pi-lab-show`,不打开 legacy drawer | +| `openPageAiDrawer()` | **Pi-only stub**:`mnote:pi-lab-show` | +| `sidebar-page-ai-runtime.js` | **已瘦身为 ~100 行 stub** | +| `sidebar-page-ai-{markdown,permission,profile,render,session,skill,target}-runtime.js` | **DELETED** | +| `agent-stream-event-router.js` | **DELETED** | +| page-settings 扩展面板 | Skills/Tools 读 `/api/mnote/tools/manifest`;已移除 MCP / hermes runs 面板与 `/api/hermes/*` fetch | +| document shell Hermes settings inject | **DELETED**(`__mnoteHermesSettingsUrl`) | + +合同测试:`page_ai_product_entry_is_pi_lab_only` + 布局无 `open-page-ai` 旧 action。 + +### 3.5 脚本 / 包装 + +| 路径 | 处置 | +|------|------| +| `scripts/reasonix-acp-wrapper.mjs` | **DELETED** | +| 仅测 hermes/opencode 的 smoke | **Wave 10**:依赖 `/api/hermes/client` 或 OpenCode host 的 20 个 smoke 已 soft-retire(exit 0 + 注释);工具类 smoke 改 `/api/mnote/tools/*` | +| OpenHub 负向检查 | 可保留「禁止生成 openhub 目录」类断言 | +| `task126` hermes bridge | 改为负向门禁:`/api/hermes/bridge` 404 + tools manifest 可达 | +| `task116` islands | AI bridge 检查改为 `/api/mnote/tools/manifest` | + +## 4. 执行波次(结果) + +| Wave | 内容 | 出口 | +|------|------|------| +| **0** | 本文清单 + 文档去历史叙事 | ✅ | +| **1** | agent/架构只描述当前主链 | ✅ AGENTS / ARCHITECTURE | +| **2** | RENAME `hermes_tools` → `mnote_agent_tools`;路由只挂 `/api/mnote/tools` | ✅ | +| **3** | DELETE `page_ai_opencode` + 代理 routes | ✅ | +| **4** | DELETE `acp_*` + Reasonix wrapper + env | ✅ | +| **5** | DELETE `hermes_client` + `hermes.rs` + `/api/hermes/*` | ✅ | +| **6** | browser 产品入口 Pi-only + 合同测试 | ✅ | +| **7** | 全量 `cargo test -p mnote-web` | ✅ 见 §5 | +| **8** | page-ai legacy 子模块 + agent-stream 物理删除;page-settings / document shell 去 Hermes | ✅ | +| **9** | agent tool schema / 头 / env / skill `agent_ids` 中性化 | ✅ | +| **10** | 活跃 smoke 去 `/api/hermes`;死 smoke soft-retire;fixture 字面量中性化 | ✅ | + +## 5. 验证门禁 + +- [x] `cargo test -p mnote-web`(lib,含 tools / pi / knowledge_rag / vault / tree;见执行记录) +- [x] 启动默认:`libsql-local` + Pi Lab on(`MNOTE_PAGE_AI_PI_LAB` 默认 true)+ legacy compat off + debug shell off +- [x] 手工或 smoke:登录 → whoami → Pi status → tools manifest → LightRAG status → Vault list → hermes/opencode 404(2026-07-25 本机 :3000) +- [x] `rg` 主链产品入口无「切换到 Hermes/OpenCode」;浮钮 / tree / drawer 均 Pi Lab +- [x] 源码:`hermes_client` / `AcpRuntimeManager` / `page_ai_opencode` 文件与 route 注册已删除 +- [x] 部署产物约定:不含 `reference-code/`、`recycle/`(仓库可保留对照) +- [x] Wave 8/9:browser 死代码删除 + schema 中性化(`mnote.agent_tool*.v1`、skill 仅 `pi`/`chat_only`) +- [x] Wave 10:smoke 工具路径 → `/api/mnote/tools`;hermes-client/opencode 死 smoke soft-retire;search/evidence fixture 中性化 + +### 执行记录(2026-07-25) + +- `cargo test -p mnote-web --lib --test-threads=1`:**849+ passed, 0 failed** +- 含合同 `page_ai_product_entry_is_pi_lab_only`、`mnote_tools_mount_is_current_only` +- 测试适配:lazy PageTree reveal、`watch_revision` 缓存键 canonicalize、layout `FILETREE_DND_RUNTIME_JS` 常量名 +- 文件门禁:`hermes_client` / `hermes.rs` / `page_ai_opencode` / `acp_*` / `hermes_tools/` / `reasonix-acp-wrapper.mjs` 均已删除;`mnote_agent_tools` + `mnote_tools` 就位 +- **Wave 8**:page-ai 7 子模块 + agent-stream 物理删除;page-settings 去 `/api/hermes`;document shell 去 Hermes settings inject;`sidebar-page-ai-runtime.js` 瘦身为 Pi-only stub +- **Wave 9(schema 中性化,已完成)**: + - `mnote.agent_tool_manifest.v1` / `mnote.agent_tool.v1` + - 响应头 `x-mnote-agent-tool-owner: mnote-web-agent-tools` + - 审计 env `MNOTE_AGENT_TOOL_AUDIT_LOG`(兼容旧 `MNOTE_HERMES_TOOL_AUDIT_LOG`) + - profile home `MNOTE_AGENT_HOME`(兼容 `HERMES_HOME` / `~/.hermes`) + - write meta:`channel=agent`、`client=mnote-agent-plugin`、`refs=agent-tool-call` + - skill `agent_ids` 仅 `pi` / `chat_only` +- **static smoke 适配**:`task-pi-lab-static-smoke.js` 读取 `routes/page_ai_pi/{mod,constants,runtime}.rs` 目录模块(不再假定 monofile `page_ai_pi.rs`);legacy opencode host 断言改为 Pi-only stub +- **主链 E2E smoke(本机 :3000,2026-07-25)**: + - `node scripts/task-pi-lab-static-smoke.js`:**270 checks passed** + - `node scripts/task-pi-lab-api-endpoint-smoke.js`:**17/17 passed** + - 登录 `POST /api/auth auth:signIn` → whoami `mnote-e2e` + - Pi `GET /api/page-ai/pi/status` enabled + `mnote.page_ai_pi.status.v1` + - tools `GET /api/mnote/tools/manifest` → `mnote.agent_tool_manifest.v1` + owner `mnote-web-agent-tools` + - LightRAG `GET /api/knowledge-rag/status` 路由挂载 200(本机 daemon 9621 未起 → `lightrag_unreachable`,API 面 OK) + - Vault `GET /api/vault/list?rootUri=…` 200 + - 负向:`/api/hermes/status`、`/api/page-ai/opencode/status` → 404 +- **可选清理(本批)**:删除 `page-ai.css` reasonix/opencode 死样式;测试 fixture `reasonix`→`pi` +- **Wave 10(smoke / fixture 清理,2026-07-25)**: + - 13 个工具 smoke:`/api/hermes/tools/mnote/{call,manifest}` → `/api/mnote/tools/{call,manifest}` + - `task116`:检查 agent tools manifest(不再打 hermes bridge) + - `task126`:`/api/hermes/bridge` 负向 404 + tools 可达 + - 20 个依赖 hermes client / OpenCode host 的浏览器或静态 smoke:**soft-retire**(保留文件、exit 0) + - fixture:search `Hermes`→`SkillGraph`;evidence `@Reasonix`→`@AtlasNote`;ai_settings skill 示例路径 `.hermes`→`.mnote/agent-profiles` + - 保留:负向合同(compat hermes provider)、`HERMES_HOME`/`~/.hermes` 兼容读、`.reasonix`/`.opencode` 目录忽略、`mnote-hermes-tool` 历史 source 兼容 +- **Wave 10.1(desktop-hot 去 OpenCode,2026-07-25)**: + - `scripts/desktop-hot.js`:删除 `ENABLE_OPENCODE` / `OPENCODE_CMD` / `SKIP_OPENCODE` / `buildDefaultOpencodeCommand` / `shouldStartOpencode` / opencode task / `MNOTE_OPENCODE_BASE_URL` + - `scripts/dev-hot.js`:不再注入 `MNOTE_OPENCODE_BASE_URL` + - 测试:`desktop-hot.test.js`、`task-dev-hot-plan-test.js` 断言无 OpenCode 分支 + +## 6. 明确不在本清单 + +- Vault 功能增强 / extension(`design/12-vault` 进行中工作保持不动;本批只做可达性 smoke) +- LightRAG provider 替换 +- Phase C 流式 apply +- 把 control-plane 强行改成 turso-remote(云首发默认 **libsql-local**) +- 删除 `design/old` 历史稿(可选后续,非阻塞) +- 测试函数名仍可带历史前缀(如 `hermes_tools_*`);**产品 schema 已中性化**,不必再以「schema 可另批」阻塞出口 +- ~~可选清理:`page-ai.css` 内 reasonix/opencode 死样式、测试 fixture 字面量中性化~~ **本批已做**:CSS 删 reasonix/opencode 死块;dev_seed/mod/mnote_tools 测试字面量 `reasonix`→`pi`(`.reasonix` 目录忽略保留) + +## 7. CodeGraph / 残留备忘 + +- `ToolCallInput`:在 `mnote_agent_tools` +- `page_ai_pi/runtime.rs` 绑定 `mnote_agent_tools::knowledge_rag` +- 测试函数名仍可带 `hermes_tools_*` 前缀(历史命名,非产品路由) +- 正式 tools **仅** `/api/mnote/tools` +- 产品 Page AI:**仅** `/api/page-ai/pi/*` + browser Pi Lab host + +--- + +**状态**:Wave 0–10 / 10.1 已完成;全量 lib 测试绿;主链 E2E smoke 绿;活跃 smoke 已切到 `/api/mnote/tools`,死 hermes/opencode smoke 已 soft-retire;desktop-hot 不再启动 OpenCode。 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 index 7f04c3bc..c3b69220 100644 --- 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 @@ -94,7 +94,8 @@ AI 不得用通用 file 工具扫 vault,只能走分层 `mnote.vault.*`(P1 - 不做跨 workspace 云端 vault 同步。 - 不把 vault 做成 FileTree / Page Tree 节点或 `tree.*` 命令对象。 - 不把 vault CRUD 塞进 tiptap / Page Aggregate。 -- 不做 OS 级自动填表扩展。 +- 不做 OS 级 / 浏览器原生级 **自动填表** 密码管理器(autofill 引擎非本设计目标)。 +- **Chrome「保存到密码箱」扩展**(登录/注册页确认后写入 vault)见独立稿:`design/12-vault/process/12-3-chrome-extension-vault-save-v1.md`(12-3);与本 Non-Goal 不冲突——12-3 P0 仅保存、不做 autofill。 - **P0 不做** 外部进程改 vault 文件后的 live FS 刷新(见 §4.2);禁止 setInterval 伪装。 - 不把 vault 写入 LightRAG / evidence / 全局搜索 snippet。 - **不** 声称导入前 `个人/密码/` 已与 vault 同等安全。 @@ -223,6 +224,9 @@ accounts: email: alice@example.com password: passwordHint: "Li@[A]***" +# 登录态(Cookie)不写在本 frontmatter 新路径: +# 见 sessions/{id}/{accountId}.json(12-3 §5.6); +# 旧字段 loginSession 仅兼容读。 # 附录密钥(API Key / Token);顶层 apikey/token 为首个同类镜像 secrets: - id: sec_... @@ -713,6 +717,7 @@ fn is_vault_sensitive_relative_path(rel: &str) -> bool { #### P2 - 协助录入确认流、attachment cookies、自注册回写、export;按 credential/run grant(可选)。 +- 浏览器侧录入主路径收敛到 **12-3 Chrome 扩展**;工作台内粘贴助手可后置或不做。 --- diff --git a/design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md b/design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md index 61fecdd4..e27a9d37 100644 --- a/design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md +++ b/design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md @@ -143,6 +143,8 @@ L-Crypto 在文件格式与迁移就绪后再做,不阻塞 agent 读路径瘦 旁路(可选,非读密阻塞路径): mnote-web /vault UI、human CRUD、共享到 AI 本 + Chrome 扩展保存到 vault(人用 human token,见 12-3;**禁止**复用本设计 agent token) + 登录态落盘:sessions/{credId}/{accountId}.json(12-3 §5.6);login/session CLI 读该路径,fallback frontmatter loginSession vault-login-helper resolve 后外站登录 / session 回写 ``` @@ -488,6 +490,7 @@ mnote-vault resolve --id --field password | 2026-07-23 | v1 初稿:本地文件 SSOT + vaultd + agent token;与 12-1 AI 策略对齐;L-Access P0 / L-Crypto P2;验收与 PR 切片 | | 2026-07-23 | P0 落地:`mnote-vault-core` + `mnote-vault` CLI(issue-token/list/get/resolve);无 web smoke 绿;UDS vaultd / web 共 core 留 P1 | | 2026-07-23 | P1d:login/session → core + CLI/UDS;web thin-wrap;session 回写 + login reuse 不依赖 mnote-web | +| 2026-07-24 | 交叉 12-3:登录态演进为 `sessions/{credId}/{accountId}.json`;CLI 语义不变,读序优先 session 文件 | --- diff --git a/design/12-vault/process/12-3-chrome-extension-vault-save-v1.md b/design/12-vault/process/12-3-chrome-extension-vault-save-v1.md new file mode 100644 index 00000000..ada576a2 --- /dev/null +++ b/design/12-vault/process/12-3-chrome-extension-vault-save-v1.md @@ -0,0 +1,929 @@ +# 12-3 [process] Chrome 扩展:站点登录/注册页保存到密码箱 v1 + +> 创建时间:2026-07-24 +> 状态:`PROCESS` +> Owner:`12-vault`(扩展客户端 + mnote-web 鉴权/跨域适配;**不**改 tree kernel / Page Aggregate) +> 建议 repo 落点:`design/12-vault/process/12-3-chrome-extension-vault-save-v1.md` +> +> 上位依据: +> - `design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md`(vault 真源、CRUD API、L0/L1、同站多账号) +> - `design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`(agent 读密通道;**本设计不复用 agent token 做写入口**) +> - `/home/lix/.agent-infra/vault-policy.md` + `$mnote-vault`(AI 读密策略;扩展默认写**人用 vault**) +> - `ARCHITECTURE.md` local-first;`AGENTS.md` 密码箱薄指针 + +--- + +## 1. Overview + +### 1.1 一句话 + +> **Chrome MV3 扩展在用户完成 MNote 远程登录后,于外站注册/登录页弹出「保存到密码箱」;默认同时保存账号密码与浏览器登录态(Cookie);登录态以「每账号独立文件」落盘;Agent 经 `$mnote-vault` 的 `login` / `session` / `resolve --field session` 复用,与 `/vault` 同一真源。** + +### 1.2 痛点 + +| 现状 | 问题 | +|------|------| +| 录入只靠 `/vault` 手填 | 跨站复制账号密码,摩擦高、易漏改 | +| 12-1 Non-Goals 写明「不做 OS 级自动填表扩展」 | 产品一期聚焦工作台合理;**录入侧**仍缺浏览器入口 | +| 12-2 agent token 只服务读密 | **不能**把 agent capability token 交给扩展当写凭证 | + +### 1.3 与 12-1 / 12-2 的关系 + +| 维度 | 12-1 | 12-2 | **12-3(本文)** | +|------|------|------|------------------| +| 真源 | `{workspace}/.mnote/vault/**` | 不变 | **不变** | +| 人用写入口 | `/vault` + `/api/vault/*` | 旁路 | **+ 浏览器扩展**(同 API 语义) | +| Agent 读密 / 登录态 | HTTP(演进中) | vaultd + capability token;`login`/`session` 已有 | **不经扩展 resolve**;扩展只 **写入** session 文件;Agent 仍走 12-2 CLI | +| 登录态存储 | frontmatter 内嵌 `loginSession`(现状) | 同左(AI 本) | **演进为每账号独立文件**(§5.6);兼容读 frontmatter | +| 鉴权主体 | 浏览器 session cookie | agent token | **人会话 / 扩展专用 human token** | +| 自动填表 | 非目标 | — | **P0 不做**;P2 可选 | + +### 1.4 硬门闩(产品) + +扩展在以下任一不满足时,**禁止**弹出保存、禁止自动提交 vault 写请求: + +1. 已配置 **远程 MNote 基址**(`baseUrl`,如 `http://127.0.0.1:3000`) +2. 已用 **MNote 账号/密码** 完成登录,且会话未过期 +3. 已选定可写 **workspace `rootUri`**(local_folder 的 `file://…`,与工作台一致) + +未登录态 UI 只允许:Options 配置 +「连接密码箱」;工具栏徽章显示「未连接」。 + +--- + +## 2. Goals / Non-Goals + +### 2.1 Goals + +1. **MVP 保存闭环**:外站检测到 password 表单 → 用户确认 → 写入当前用户 vault(create 或更新同站条目)。 +2. **默认保存登录态**:确认保存时 **默认勾选**「同时保存登录态」;扩展用 `chrome.cookies` 抓取目标 origin 的 Cookie,写入 **每账号独立 session 文件**(§5.6)。 +3. **Options 引导**:远程地址 + 账号密码登录 + 选择 workspace(rootUri)+ 连接状态展示。 +4. **与 `/vault` 同真源**:`POST /api/vault/items`、`PATCH /api/vault/items/{id}`、`PUT …/session`、`GET /api/vault/list`;字段对齐 `VaultCreateInput` + session file schema。 +5. **安全默认**:密钥与 Cookie 只经 extension **background** 出站;content script **不**持长期 token / cookie 明文;审计无 value / cookieHeader。 +6. **可选同步 AI 本**:保存后二次确认才 `share-to-ai`;勾选时 **连同 session 文件** 复制到 AI 本(否则 Agent 无法 `login` 复用)。 +7. **Skill / Agent 可调用**:`$mnote-vault` + vault-policy 明确 `login` / `session` / `resolve --field session` 稳态(§6.5)。 +8. **可安装、可调试**:Manifest V3;本地 unpacked 加载;文档化 host_permissions(含 cookies)。 + +### 2.2 Non-Goals(本设计不承诺 / 明确不做) + +| 项 | 说明 | +|----|------| +| P0 自动填充 | 不做 Bitwarden 式 autofill;仅「保存」 | +| Agent capability token 复用 | 扩展 **禁止** 使用 `mnote-vault` agent token / vaultd UDS 写库 | +| 改 kernel / tree.* | vault 仍非图节点 | +| 云端跨机 vault 同步 | 仍 local-first;远程只是 mnote-web 可达地址 | +| 完整密码管理器对标 | 无 TOTP 引擎、无浏览器原生 Password API 劫持(P2 再评估) | +| Firefox/Safari | P0 只 Chrome/Chromium MV3;API 预留可移植 | +| 在 content script 内直接 fetch vault API | 禁止(token 面扩大) | +| 默认把所有保存项 share-to-ai | 禁止静默 | + +### 2.3 成功标准(设计批准后实现门闩) + +- [x] Options:填 baseUrl + 登录成功 → whoami 显示用户(扩展 CONNECT + API smoke 签发 mnext1) +- [x] 未登录时打开任意站 password 表单 → **无**可保存(弹层提示去连接且禁用保存) +- [ ] 登录后在示例站提交登录表单 → 弹层预填 url/username/password → 确认 → `/vault` 列表可见新条目(**Chrome 手工**) +- [x] **默认勾选「同时保存登录态」** → API smoke 确认磁盘 `sessions/{credId}/{accountId}.json` +- [x] 同 origin 已有条目 → 可选「追加账号」写入 `accounts[]`;**新账号自有 session 文件**(扩展 UI) +- [ ] share-to-ai 勾选时 AI 本同时有 session 文件;Agent `mnote-vault login --id` 可 `mode=session` 复用(**Chrome 手工**) +- [x] `$mnote-vault` skill 写明登录态调用(`login` / `session`);vault-policy 待同步 +- [x] audit 有 `session_put`;L0 **无** password / cookieHeader 明文(smoke 断言) +- [x] 错误 baseUrl / 401 → 明确文案;token 吊销后 401(smoke) + +--- + +## 3. 问题与方案选型 + +### 3.1 写入通道 + +| 方案 | 优点 | 缺点 | 结论 | +|------|------|------|------| +| A. 扩展 → mnote-web `/api/vault/*`(人会话) | 与工作台一致;已有 create/update | 需跨域鉴权 | **采用** | +| B. 扩展 → Native Messaging → 本机写文件 | 不依赖 3000 | 无「远程地址」;安装重;与 local-first 桌面强绑定 | P2 备选「纯本机」 | +| C. 扩展 → vaultd agent token | 不启 web | token 设计给 agent 读;无完整 human CRUD 策略 | **禁止** | + +### 3.2 鉴权形态 + +| 方案 | 说明 | 推荐阶段 | +|------|------|----------| +| **E1. Session cookie 跨域** | `POST /api/auth` 后带 `mnote_session`;扩展 fetch `credentials: 'include'` | 本机 localhost 可试;跨站 SameSite 脆弱 | +| **E2. Extension human token(推荐)** | 登录成功后服务端签发 **仅 vault.view + vault.edit** 的 Bearer;扩展存 `chrome.storage.session` | **MVP 主路径** | +| **E3. 复用 `/api/auth/mnote-web-token`** | 若现网已有可给 API 的 token | 实现时审计 scope;不足则走 E2 | + +**推荐默认(钉死):E2。** +Cookie 路径(E1)可作为同机 dev 旁路,**不得**作为唯一生产假设。 + +E2 token 逻辑声明(与 12-2 agent token **分 aud/scope**,禁止混用): + +```json +{ + "v": 1, + "iss": "mnote-web", + "aud": "chrome-extension-vault", + "sub": "user:", + "scope": ["vault.view", "vault.edit"], + "rootUriAllow": ["file:///…可选绑定…"], + "iat": 0, + "exp": 0, + "jti": "uuid" +} +``` + +- **禁止** scope 含 `vault.resolve` / AI resolve。 +- **禁止** 与 `mnv1.*` agent token 同密钥、同 aud。 +- TTL 默认 **7d**(可 Options 续期 / 重新登录);revoke 按 jti。 +- 传输:`Authorization: Bearer `。 + +### 3.3 远程地址与 rootUri + +```text +Options 配置: + baseUrl = https://mnote.example 或 http://127.0.0.1:3000 + account = 用户账号/邮箱 + password = 仅登录瞬间使用;成功后**不**长期明文落盘(见 §7) + rootUri = (可选)file://… 覆盖;默认自动解析 + sourceKind = local_folder(固定) +``` + +- `baseUrl` 规范化:去尾 `/`;仅 `http:`/`https:`;禁止 `javascript:` 等。 +- **首次连接**后把 baseUrl 写入 `chrome.storage.sync`(可同步)或 `local`;token 只进 **session** storage。 +- **默认 rootUri(钉死)**:登录成功后 `POST /api/local-folder/workspaces/default` → 账号固定 + `…/users//workspaces/my-space` 的 `file://` rootUri(与 web 壳默认 local_folder 一致)。 + Options **不必手填**;「高级」仅用于写到其它已授权 local_folder。 +- 多 workspace / 自定义目录:仍可覆盖 rootUri(P1 可做下拉列表)。 + +--- + +## 4. 目标架构 + +### 4.1 组件图 + +```text +┌─────────────────────────────────────────────────────────────┐ +│ 外站页面(任意 origin) │ +│ content script:侦测 password 字段 / 提交;发消息给 background │ +│ 不持 token;不直接打 MNote API │ +└────────────────────────────┬────────────────────────────────┘ + │ chrome.runtime.sendMessage + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Extension Service Worker(background) │ +│ - 持 baseUrl + human token + rootUri │ +│ - 保存弹层协调 / 工具栏 badge │ +│ - fetch(`${baseUrl}/api/vault/...`) │ +│ - host_permissions: 用户配置的 baseUrl(optional 动态) │ +└────────────────────────────┬────────────────────────────────┘ + │ HTTPS + Bearer + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ mnote-web │ +│ POST /api/auth → 登录 │ +│ POST /api/vault/extension/token → 签发 E2(新) │ +│ GET /api/auth/whoami → 校验会话/token │ +│ GET /api/vault/list → 同站匹配 │ +│ POST /api/vault/items → create │ +│ PATCH /api/vault/items/{id} → update / 追加账号 │ +│ PUT /api/vault/items/{id}/session → 写每账号 session 文件 │ +│ POST /api/vault/items/{id}/share-to-ai → 可选(含 session)│ +└────────────────────────────┬────────────────────────────────┘ + │ 文件写 + ▼ + {workspace}/.mnote/vault/ + entries/{credId}.md # 账号密码真源 + sessions/{credId}/{acc}.json # 每账号登录态(§5.6) +``` + +### 4.2 仓库落点(建议) + +| 路径 | 职责 | +|------|------| +| `extensions/mnote-vault/`(新建) | MV3 扩展源码:manifest、background、content、options、popup | +| `rust/crates/mnote-web/src/routes/vault_extension.rs`(或并入 `vault.rs`) | extension token 签发/吊销;可选 match-by-url 查询 | +| `browser/vault-workbench-runtime.js` | 可选:「复制连接信息到扩展」 | +| `design/12-vault/process/12-3-…` | 本文 | +| 文档 | `scripts/TESTING_REFERENCE.md` 增补 extension smoke | + +**不**放入 `recycle/`;**不**改 `core-domain` 图模型。 + +### 4.3 Manifest V3 最小权限 + +```json +{ + "manifest_version": 3, + "name": "MNote Vault", + "permissions": ["storage", "activeTab", "scripting", "alarms", "cookies"], + "optional_host_permissions": ["http://*/*", "https://*/*"], + "host_permissions": [], + "background": { "service_worker": "background.js", "type": "module" }, + "action": { "default_popup": "popup.html" }, + "options_page": "options.html", + "content_scripts": [{ + "matches": ["http://*/*", "https://*/*"], + "js": ["content.js"], + "run_at": "document_idle", + "all_frames": false + }] +} +``` + +说明: + +- **`cookies` 权限**:默认保存登录态时,background 用 `chrome.cookies.getAll({ url })` 抓目标 origin Cookie(**禁止** content script 读 cookie)。 +- **optional_host_permissions**:用户在 Options 确认 baseUrl 后 `chrome.permissions.request` 只放行该 origin;目标站默认靠 content_scripts matches(仅读 DOM,不出站)。保存登录态时,对目标站 origin 也需 host 权限(`cookies` API 要求)。 +- 若政策要求更紧:content_scripts 改为 `activeTab` + 用户点工具栏再注入(牺牲「自动侦测」)。**P0 推荐:全站 content script + 保存必须用户点确认**(非静默上传)。 + +--- + +## 5. 用户流程 + +### 5.1 首次连接(Options) + +```text +1. 打开扩展 Options +2. 输入 baseUrl →「测试连接」→ GET {baseUrl}/api/health 或 /api/auth/whoami(未登录可 401 但仍可达) +3. 输入 MNote 账号/密码 →「登录」 +4. 扩展 background: + POST {baseUrl}/api/auth { provider password, flow signIn, … } + 成功后 POST {baseUrl}/api/vault/extension/token (Cookie 或临时会话) + 或 auth 响应内嵌 extensionToken(实现可选合并) +5. 选择/粘贴 rootUri →「保存配置」 +6. Badge:已连接 · 用户 email 缩写 +``` + +密码处理: + +- Options 表单内存持有至登录成功; +- **成功后立即清除** DOM 与内存中的 password; +- **禁止** `chrome.storage` 持久化 MNote 登录密码; +- 仅持久化:`baseUrl`、`rootUri`、`userId`/`email`(非敏感)、token 放 **session**(浏览器重启需重新登录或 refresh)。 + +### 5.2 外站保存(MVP) + +```text +触发(满足其一即可进入「候选」状态): + T1. 页面存在 input[type=password],且用户焦点离开该字段 / 输入长度≥阈值 + T2. form submit 且 form 内含 password + T3. 用户点击扩展工具栏「保存当前页账号」 + +门闩: + 未连接 → 仅 toast/小条:「打开 MNote Vault 扩展并登录」 + 已连接 → 弹出确认层(扩展 page 或 iframe 隔离 UI,见 §5.4) + +确认层字段: + - title(默认:document.title 截断 80 或 hostname) + - url(默认:location.origin + pathname 的 login 路径;可编辑) + - username / email(从 autocomplete=username / email / name=user 启发式) + - password(来自 password input;可显示切换) + - 匹配结果:若 list 命中同站 → 「新建条目」|「追加到已有 · title」 + - **「同时保存登录态」checkbox:默认勾选**(用户可取消) + - 可选 checkbox:同步到 AI 密码本(默认关;勾选时连同 session 文件) + - 按钮:保存 / 取消 + +确认后 background: + 1) create 或 update(账号密码) + 2) 若「同时保存登录态」仍勾选: + chrome.cookies.getAll({ url: pageUrl 或 origin }) + → 拼 cookieHeader + → PUT /api/vault/items/{id}/session + { rootUri, accountId, cookieHeader, source: "chrome_extension" } + 3) 若勾选 share-to-ai → POST share-to-ai(服务端复制 credential + session 文件) + 4) toast 成功/失败(session 失败不回滚 credential,但提示「账号已存、登录态未写入」) +``` + +### 5.3 同站匹配规则(P0) + +服务端或客户端匹配(P0 可客户端 list 后过滤,条目量小时足够): + +1. 取候选 URL 的 **registrable origin**(`https://github.com`)与 path 前缀(可选)。 +2. 对 list 中每条 credential 的 `url` + `urls[]`: + - origin 相等 → 候选; + - 多条 → 按 `updatedAt` 降序展示前 N=5。 +3. 用户选「追加到已有」: + - `PATCH` 合并 `accounts[]` 新 slot(生成 `acc_*` id); + - 顶层 `username`/`password` 是否更新: + - **默认**:仅追加 slot,**不**改 primary 镜像(避免踩掉主账号); + - 勾选「设为主账号」时再写 primary。 + +实现可新增只读辅助(P1): + +```text +GET /api/vault/match?rootUri=&url=https://example.com/login +→ { items: [ L0 投影… ] } +``` + +P0 无此 API 时用 `GET /api/vault/list` + 客户端过滤。 + +### 5.4 确认 UI 隔离 + +| 方式 | 说明 | 推荐 | +|------|------|------| +| Content script 注入 shadow DOM | 实现快;样式污染风险;密码进页面 JS 上下文 | MVP 可接受若确认层只读自 form | +| `chrome.action` popup 预填 | 需用户点图标;最安全 | 与 T3 共用 | +| 独立 extension 页 `chrome.windows.create` | 重 | 备选 | + +**P0:shadow DOM 确认层 + 工具栏手动入口双轨。** +密码值从 form 读入后立即通过 message 传 background,确认层可用 `type=password` 掩码展示;取消则丢弃。 + +### 5.5 注册页 vs 登录页 + +不强制区分 DOM:同一保存路径。 +启发式:URL/path 含 `signup|register|sign-up` 时 title 默认加「注册」前缀;不改变 schema。 + +### 5.6 每账号独立登录态文件(核心演进) + +#### 5.6.1 为何不继续只靠 frontmatter + +| 现状(12-1 / 12-2 已落地) | 问题 | +|---------------------------|------| +| `loginSession` 嵌在 `entries/{credId}.md` frontmatter | **整条 credential 只有一份** session;同站多账号互相覆盖 | +| Cookie 与密码同文件 | 改 session 会 bump credential revision;审计/diff 面大 | +| 扩展默认抓 cookie 后无处按账号落盘 | 无法满足「每个账号各自登录态」 | + +**钉死:登录态 = 独立文件,按 credential + account 一文件。** +frontmatter 内 `loginSession` **仅兼容读**(迁移期);新写入一律走 session 文件。 + +#### 5.6.2 磁盘布局 + +```text +{workspace}/.mnote/vault/ + entries/ + {credId}.md # 账号/密码/accounts[] 真源(无 cookie 明文) + sessions/ + {credId}/ + primary.json # 无 accounts[] 或主账号(accountId 省略 / "primary") + {accountId}.json # 对应 accounts[].id,如 acc_01H… + attachments/… # 既有附件(大 cookie 导出仍可走附件,非默认) + audit.jsonl + index.json +``` + +- 路径一律相对 vault root;禁止 `..`。 +- 文件权限:与 vault 一致(建议 `0600` 文件 / `0700` 目录)。 +- **删除 credential** → 同步删除 `sessions/{credId}/` 整目录。 +- **删除 account slot** → 删除对应 `{accountId}.json`;`primary` 若被新主账号替换则按 merge 规则重命名/覆盖。 +- **share-to-ai** → 复制 `entries` 到 AI 本时 **一并复制** `sessions/{credId}/**` 到 AI vault 同相对路径;否则 Agent 无法 `login` 复用。 + +#### 5.6.3 Session 文件 schema + +```json +{ + "schema": "mnote.vault.session.v1", + "credentialId": "cred_01H…", + "accountId": "acc_01H…", + "cookieHeader": "session=…; other=…", + "cookies": [ + { + "name": "session", + "value": "…", + "domain": ".example.com", + "path": "/", + "secure": true, + "httpOnly": true, + "sameSite": "lax", + "expirationDate": 1735689600 + } + ], + "origin": "https://example.com", + "expiresAt": "2026-07-31T00:00:00Z", + "lastLoginAt": "2026-07-24T12:00:00Z", + "source": "chrome_extension", + "updatedAt": "2026-07-24T12:00:00Z", + "revision": 1 +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `schema` | 是 | 固定 `mnote.vault.session.v1` | +| `credentialId` / `accountId` | 是 | 与路径一致;`accountId` 无多账号时用 `"primary"` | +| `cookieHeader` | 推荐 | 拼好的 `Cookie` 请求头(Agent `login` 主消费) | +| `cookies[]` | 推荐(扩展写) | 结构化;便于工作台展示/过期判断;与 cookieHeader 同步 | +| `origin` | 推荐 | 抓取时的 origin | +| `expiresAt` | 推荐 | 会话逻辑过期(默认 now+7d,`MNOTE_VAULT_SESSION_TTL_HOURS`) | +| `source` | 是 | `chrome_extension` \| `api` \| `human_bridge` \| `browser` \| `login_api` | +| `revision` | 是 | 乐观并发 | + +**L0 投影**:list/get 只暴露 `hasLoginSession` / `sessionExpiresAt` / 可选 `sessionAccountIds[]`;**永不**返回 `cookieHeader` / `cookies[].value`。 + +#### 5.6.4 Core 读路径(兼容) + +`mnote-vault-core` / `login` / `put_ai_vault_session` 演进顺序: + +1. **写**:始终写 `sessions/{credId}/{accountId}.json`;可选同步清掉 frontmatter `loginSession`(或保留镜像一期,二期去掉)。 +2. **读(login 复用)**: + a. 若指定 `--account-id` → 只读该文件; + b. 否则读 `primary.json`,否则 `accounts[0].id.json`; + c. **fallback**:frontmatter `loginSession`(旧数据)。 +3. **新鲜度**:同现网 `login_session_is_fresh`(`expiresAt` + TTL)。 + +#### 5.6.5 扩展抓 Cookie 规则 + +```text +url = 确认层 url 或 tab.url 的 origin(至少 scheme+host+port) +chrome.cookies.getAll({ url }) +→ 过滤: + - 排除超大 value(单 cookie > 8KiB 截断并记 note) + - 可选排除已知 tracking 名(P1 列表);P0 全量 origin cookies +→ cookieHeader = cookies.map(c => `${c.name}=${c.value}`).join('; ') +→ cookies[] 保留 domain/path/secure/httpOnly/sameSite/expirationDate +``` + +- 仅 background 持有 cookie 明文;确认层 **不**展示完整 cookie 列表(可显示「已捕获 N 条 Cookie」)。 +- 用户取消「同时保存登录态」→ 跳过 PUT session。 +- 登录**前**表单提交瞬间可能尚无 session cookie: + - P0:仍保存当时 cookies(可能为空或仅 CSRF);toast 可提示「若刚登录,建议登录成功后再点工具栏『更新登录态』」。 + - P1:`webNavigation` / 登录成功 URL 启发式后再二次捕获(另 checklist)。 + +#### 5.6.6 与 attachments 的关系 + +- 12-1 附件 `kind: cookies` **保留**为大文件/导出旁路。 +- **默认路径是 session 文件**,不是附件。 +- 禁止把 cookieHeader 再写进 notes_markdown。 + +--- + +## 6. API 合同 + +### 6.1 复用(已存在) + +| 方法 | 路径 | 用途 | 鉴权 | +|------|------|------|------| +| POST | `/api/auth` | 登录拿会话 | 公开 | +| GET | `/api/auth/whoami` 或 `/api/auth/session` | 校验身份 | session/token | +| GET | `/api/vault/list?rootUri=&sourceKind=local_folder` | L0 列表 / 匹配 | view | +| POST | `/api/vault/items` | 创建 | edit | +| PATCH | `/api/vault/items/{id}` | 更新 / 追加账号 | edit | +| POST | `/api/vault/items/{id}/share-to-ai` | 可选同步 AI 本(**须含 session 文件**) | edit | +| POST | `/api/vault/ensure` | 确保目录 | 可写 | +| PUT | `/api/vault/ai/items/{id}/session` | AI 本 session 回写(既有;演进写文件) | AI + session scope | + +**Create body 示例(扩展 → mnote-web):** + +```json +{ + "rootUri": "file:///home/user/Notes", + "sourceKind": "local_folder", + "title": "GitHub", + "url": "https://github.com/login", + "username": "alice", + "password": "plaintext-from-form", + "email": "alice@example.com", + "tags": ["from-extension"], + "folderPath": "imported/browser", + "notesMarkdown": "Saved via MNote Vault extension" +} +``` + +响应:与现网一致,L0 投影(password **masked**)。 + +**Update 追加账号(语义对齐 12-1 accounts[]):** + +```json +{ + "rootUri": "file:///…", + "accounts": [ + { "id": "acc_existing", "label": "work", "username": "…", "password": "…" }, + { "id": "acc_new", "label": "from-extension", "username": "…", "password": "…" } + ] +} +``` + +注意:`accounts` 在现网为 **全量替换**(`VaultUpdateInput.accounts: Option>`)。扩展必须 **先 get 再 merge 再 patch**,禁止只发新 slot 导致清空旧账号。 + +### 6.2 新增(mnote-web,MVP 建议) + +#### `POST /api/vault/extension/token` + +**前置**:已登录 session(Cookie)或刚完成 auth 同请求链。 + +Request: + +```json +{ + "clientId": "chrome-extension", + "extensionId": "optional-chrome-runtime-id", + "ttlHours": 168 +} +``` + +Response: + +```json +{ + "ok": true, + "result": { + "token": "mnext1.…", + "expiresAt": "2026-07-31T00:00:00Z", + "scope": ["vault.view", "vault.edit"], + "userId": "…", + "email": "…" + } +} +``` + +#### `POST /api/vault/extension/token/revoke` + +Body:`{ "jti": "…" }` 或吊销当前 Bearer。 + +#### `PUT /api/vault/items/{id}/session`(人用 / 扩展 · 新增) + +**鉴权**:session cookie 或 E2 Bearer(`vault.edit`)。 +**写目标**:用户 vault(非 AI 本);路径 `sessions/{id}/{accountId}.json`。 + +Request: + +```json +{ + "rootUri": "file:///home/user/Notes", + "sourceKind": "local_folder", + "accountId": "primary", + "cookieHeader": "session=abc; other=1", + "cookies": [ + { + "name": "session", + "value": "abc", + "domain": ".example.com", + "path": "/", + "secure": true, + "httpOnly": true, + "sameSite": "lax", + "expirationDate": 1735689600 + } + ], + "origin": "https://example.com", + "expiresAt": "2026-07-31T00:00:00Z", + "source": "chrome_extension", + "expectedRevision": null +} +``` + +规则: + +- `cookieHeader` 与 `cookies[]` 至少一个非空;两者皆有时以 `cookies[]` 重算 header 为准(防不一致)。 +- `accountId` 缺省 → `"primary"`。 +- 响应 L0:`{ ok, result: { credentialId, accountId, hasLoginSession: true, sessionExpiresAt, revision } }`,**无** cookie 明文。 +- audit:`action=session_put`,`source=chrome_extension`,无 value。 + +#### `GET /api/vault/items/{id}/session`(可选 P1) + +- 默认 **不** 返回 cookie(仅 meta)。 +- 人用工作台「查看登录态元数据」用;扩展 P0 **不需要** GET。 + +#### share-to-ai 合同补强 + +`POST /api/vault/items/{id}/share-to-ai` 实现必须: + +1. 复制 credential 到 AI 本(既有); +2. **复制** `sessions/{id}/**` → AI vault 同相对路径; +3. 若仅有 frontmatter `loginSession` 无文件 → 迁移写出 AI 侧 `primary.json` 再清/保留源侧策略按 core 统一。 + +否则 Agent 勾选同步后仍 `login` 不到 cookie。 + +#### 鉴权中间件扩展 + +`RequestContext` 识别: + +1. 现有 `mnote_session` cookie +2. **新增** `Authorization: Bearer mnext1.…`(extension human token) + +映射到同一 `actor_id`;vault 路径 `require_authenticated` 通过即可。 +**reveal** 是否允许 extension token: + +- **P0:允许**(扩展未来 autofill 需要);须审计 `source=extension` +- 若产品收紧:P0 仅 list/create/update,reveal 仍要 web session + +**推荐 P0:extension token 允许 list / create / update / ensure / **session put**;reveal 默认关(scope 不含 vault.reveal);P2 autofill 再加 `vault.reveal`。** +E2 scope 逻辑声明可写 `["vault.view", "vault.edit"]`,`vault.edit` **包含** session 文件写入(不必单独 `vault.session`,避免 token 面碎片化)。 + +### 6.5 Agent / Skill 调用登录态(`$mnote-vault` 合同) + +> **扩展只写入;Agent 只经 12-2 CLI / core 消费。** 禁止 Agent 为读 session 去扫 `.mnote/vault/sessions/**` 文件。 + +#### 6.5.1 稳态命令(AI 必须会) + +```bash +# 0. 环境(一次) +export MNOTE_VAULT_TOKEN_FILE="${MNOTE_VAULT_TOKEN_FILE:-$HOME/.config/mnote/vault-tokens/default.token}" +# 可选:MNOTE_AI_VAULT_ACTOR / MNOTE_VAULT_WORKSPACE + +# 1. 选型(未知 id) +mnote-vault list +# L0 可见 hasLoginSession / sessionExpiresAt(无 cookie 明文) + +# 2. 优先复用登录态(推荐主路径) +mnote-vault login --id +# 成功:mode=session | reused → 响应含 cookieHeader(仅 stdout 一次;勿贴聊天) +# 过期/无 session:走 api_first 或 human_required + +# 2b. 多账号时指定账号(session 文件按 accountId) +mnote-vault login --id --account-id + +# 3. 人机验证后回写(chrome-bridge / 扩展已写人用库后 share-to-ai) +mnote-vault session --id --cookie-header 'name=value; …' --source human_bridge +# 可选:--account-id + +# 4. 只要 cookie 明文管道(实现演进;与 login 二选一) +# mnote-vault resolve --id --field session [--account-id ] [--raw] +``` + +| 意图 | 命令 | 读哪里 | +|------|------|--------| +| 复用未过期 Cookie 访问外站 | `login` | `sessions/{id}/{account}.json` → fallback frontmatter | +| 浏览器验证后落盘 | `session` | 写同上路径 | +| 只要密码填表 | `resolve --field password` | credential md | +| 只要 Cookie 不做登录流程 | `resolve --field session`(P1 落地)或 `login` 取 `cookieHeader` | session 文件 | + +#### 6.5.2 Agent 行为规则(写入 skill) + +1. **需要已登录外站状态时**:先 `login --id`,看 `mode`;`session`/`reused`/`ok` 用返回的 `cookieHeader` 调目标 API / 注入浏览器。 +2. **禁止** `Read`/`cat`/`grep` `.mnote/vault/sessions/**` 或 credential 内 cookie。 +3. **禁止** 把 `cookieHeader` / token 写入聊天、commit、issue。 +4. `human_required` → 用 chrome-bridge 完成验证 → `session` 回写;**不要**为 session 去起 mnote-web。 +5. 人用扩展写入的 session 在 **用户 vault**;Agent 默认读 **AI 本** → 须用户 **share-to-ai**(或显式配置 actor 指向人用本,非默认)。 +6. 多账号:list/get 看 `accounts[]` + `hasLoginSession`;`login --account-id` 与扩展写入的 `accountId` 对齐。 + +#### 6.5.3 文档落点(实现 Checklist 同步改) + +| 文件 | 变更 | +|------|------| +| `/home/lix/.agent-infra/vault-policy.md` | Session 语义改为「每账号文件」;登录稳态表保留 | +| `skills/mnote-vault/SKILL.md`(全局 symlink) | 增加「登录态文件 + 调用示例」专节 | +| 12-2 | 交叉引用:session 存储演进见 12-3 §5.6;CLI 语义不变 | +| 12-1 schema | 注明 `loginSession` frontmatter 兼容;新写走 `sessions/` | + +#### CORS(仅 extension 需要时) + +若 background 为 extension origin: + +- 对 `/api/vault/*` 与 `/api/vault/extension/*`、`/api/auth`: + - `Access-Control-Allow-Origin: chrome-extension://` **或** 动态反射已登记 extensionId + - `Access-Control-Allow-Headers: Authorization, Content-Type` + - 不需要 `Allow-Credentials` 若纯 Bearer + +**更干净:不靠 CORS,仅用 extension host_permissions 直连**(Chrome 扩展跨域 fetch 不受页面 CORS 限制)。 +**实现结论:MVP 以 host_permissions + Bearer 为主;服务端 CORS 仅在将来 Web 页面桥接时需要。** + +### 6.3 错误码(扩展可分支) + +| code | HTTP | 扩展动作 | +|------|------|----------| +| `vault_auth_required` | 401 | 清 token,引导 Options 登录 | +| `vault_extension_token_invalid` | 401 | 重新签发 | +| `vault_extension_token_expired` | 401 | 重新登录 | +| `vault_root_required` / `vault_root_invalid` | 400 | Options 检查 rootUri | +| `vault_permission_denied` | 403 | 提示 workspace 无写权限 | +| `vault_revision_conflict` | 409 | re-get 后重试 merge | +| `vault_mask_sentinel_rejected` | 400 | 勿提交掩码串 | +| `network_unreachable` | — | 检查 baseUrl / 本机 3000 | + +### 6.4 审计 + +沿用 `{workspace}/.mnote/vault/audit.jsonl`: + +```json +{"ts":"…","action":"create","actorId":"user_x","credentialId":"cred_…","requestId":"…","ok":true,"source":"chrome_extension"} +{"ts":"…","action":"session_put","actorId":"user_x","credentialId":"cred_…","accountId":"primary","requestId":"…","ok":true,"source":"chrome_extension"} +``` + +`source` 字段:实现时在 append_vault_audit 增加可选 `source`(无则兼容缺省)。**禁止** value / cookieHeader。 + +--- + +## 7. 安全设计 + +### 7.1 威胁与缓解 + +| 威胁 | 缓解 | +|------|------| +| 钓鱼站诱导保存到假 baseUrl | Options 显示完整 baseUrl;首次连接确认;可选证书钉扎不做 P0 | +| 恶意页面读 extension 消息 | content 只发「候选字段」;token 永不下行 content;响应不回传 password / cookie | +| XSS 页面窃取已输入密码 | 与浏览器密码管理器同类风险;确认层不把 token 注入页面 | +| token 失窃 | session storage;短 TTL;revoke;scope 无 resolve | +| Cookie 被 content 窃取 | 仅 background `chrome.cookies`;不回传 content | +| 磁盘 session 文件泄露 | vault 目录权限;path deny 对 agent 通用工具;只经 CLI | +| 扩展被恶意更新 | 自用 unpacked / 签名发布策略另案;P0 本地加载 | +| 日志/崩溃转储 | background 不 console.log password / cookieHeader;错误上报脱敏 | +| 静默批量上传 | **必须用户确认**;无「自动保存所有表单」默认开 | +| AI 本扩大攻击面 | share-to-ai 默认关 + 二次确认;复制 session 须同确认 | + +### 7.2 与 12-1 安全分级 + +| 级 | 扩展行为 | +|----|----------| +| L0 | list/match 元数据可缓存内存短时(TTL≤5min),**不**持久化到 disk 含 hint 以外 secret;session 仅 `hasLoginSession` | +| L1 | create/update 提交明文 password;session put 提交 cookie **仅** HTTPS 到 baseUrl;扩展本地不落盘 | +| L2 | **不提供** resolve;禁止 agent token;session **写**允许、**读明文**不对 extension | + +### 7.3 与 chrome-bridge / QA 关系 + +- chrome-bridge 用于 agent **人机验证** session 回写(12-2),**不是**本扩展。 +- 本扩展是 **人** 的录入工具;QA 可用 Playwright 装 unpacked 扩展做 smoke,不与 Hermes QA 默认路径耦合。 + +--- + +## 8. 扩展模块地图 + +```text +extensions/mnote-vault/ + manifest.json + background/ + index.js # 消息路由、API 客户端、badge + auth.js # login / token / whoami + vault-api.js # list/create/update/session/share + cookies.js # chrome.cookies → cookieHeader + cookies[] + match.js # origin 匹配 + content/ + detect.js # password 表单侦测 + save-prompt.js # shadow DOM 确认层(默认勾选保存登录态) + options/ + options.html + options.js + popup/ + popup.html # 状态 + 快捷「保存本页」+「更新登录态」+ Options + popup.js + shared/ + messages.js # 消息类型常量 + normalize-url.js + README.md # 安装:chrome://extensions 开发者模式 +``` + +### 8.1 消息协议(内部) + +```ts +// content → background +type Msg = + | { type: "vault/candidate"; payload: { url: string; title: string; username?: string; email?: string; password: string; pageUrl: string } } + | { type: "vault/ping" }; + +// background → content +type Reply = + | { type: "vault/status"; connected: boolean; email?: string } + | { type: "vault/save-result"; ok: boolean; itemId?: string; sessionSaved?: boolean; message?: string }; + +// popup/options → background +type Ctrl = + | { type: "vault/login"; baseUrl: string; account: string; password: string } + | { type: "vault/logout" } + | { type: "vault/set-root"; rootUri: string } + | { type: "vault/save-confirmed"; payload: SavePayload } + | { type: "vault/refresh-session"; payload: { itemId: string; accountId?: string; pageUrl: string } }; + +type SavePayload = { + title: string; + url: string; + username?: string; + email?: string; + password: string; + matchItemId?: string; // 追加到已有 + setPrimary?: boolean; + saveSession: boolean; // 默认 true + shareToAi: boolean; // 默认 false + pageUrl: string; +}; +``` + +### 8.2 刷新模型 + +- **禁止** content/background `setInterval` 轮询 vault list。 +- 匹配:保存前 **单次** list/match;结果可 memory cache 至多 5 分钟或 rootUri 变更时失效。 +- 连接状态:登录/登出/401 时更新 badge;可用 `chrome.alarms` 做 token 到期前提醒(非轮询 vault)。 + +--- + +## 9. 分期 + +| 阶段 | 范围 | 依赖 | +|------|------|------| +| **P0 MVP** | Options 连接;Bearer;保存确认层;create;**默认保存登录态 → session 文件**;list 匹配;toolbar 手动保存;skill/policy 更新 | mnote-web token + session PUT;core 写 `sessions/` | +| **P1** | get+merge `accounts[]` + 每账号 session;`login --account-id`;`/vault/match`;share-to-ai 复制 session;workbench「复制到扩展」;登录成功后二次抓 cookie | P0 | +| **P2** | 自动填充(`vault.reveal`);`resolve --field session`;提交成功智能检测;Firefox | 安全评审 | +| **P3** | Native Messaging 纯本机旁路;导入浏览器 CSV;TOTP;去掉 frontmatter `loginSession` 镜像 | 另设计 | + +### 9.1 与 12-1 P2「协助录入」关系 + +12-1 §6 P2「协助录入确认流」可与本扩展合并叙事:**浏览器侧录入 = 12-3;工作台内粘贴助手可后置或不做。** + +--- + +## 10. 实现 Checklist(批准设计后执行) + +### PR-A — 设计与开关 + +- [x] 本文合入 `design/12-vault/process/` +- [x] 12-1 / 12-2 交叉引用 session 文件演进(§5.6) +- [ ] Feature flag(可选):`MNOTE_VAULT_EXTENSION=1` 控制 token 路由是否注册(P0 随 `MNOTE_VAULT=1`) + +### PR-B — mnote-web + core(session 文件) + +- [x] `POST /api/vault/extension/token` + revoke +- [x] Bearer `mnext1` 解析接入 `RequestContext` / vault 鉴权 +- [x] scope:view+edit(含 session put);**无** resolve +- [x] `PUT /api/vault/items/{id}/session` → 写 `sessions/{id}/{accountId}.json` +- [x] core:`put_login_session` / `login` 读路径优先 session 文件,fallback frontmatter +- [x] share-to-ai **复制** session 目录 +- [x] audit `source=chrome_extension`;`session_put` 无 cookie 明文 +- [x] 单测:签发、revoke、session 文件读写(`vault_extension_token` + `session_file_put_resolve_and_share_copy`) +- [x] API smoke:`scripts/vault-extension-api-smoke.js`(auth → mnext1 → create → PUT session → disk → revoke) +- [ ] (P1)`GET /api/vault/match`;`login --account-id` + +### PR-C — 扩展 MVP + +- [x] `extensions/mnote-vault` MV3 脚手架(含 `cookies` 权限) +- [x] Options:baseUrl / 登录 / rootUri +- [x] background vault-api + cookies 抓取 +- [x] content 侦测 + 确认层(**默认勾选保存登录态**) +- [x] popup:状态 + 保存本页 + 更新登录态 +- [x] README 安装步骤 + +### PR-D — Skill / Policy + +- [x] 更新 `/home/lix/.agent-infra/vault-policy.md`(每账号 session 文件 + 调用) +- [x] 更新 `skills/mnote-vault/SKILL.md`(login/session 与文件语义) +- [x] 确认全局 symlink(`.grok` / Codex)→ 仓库 `skills/mnote-vault` + +### PR-E — 验收 + +- [x] API 层:`node scripts/vault-extension-api-smoke.js` 对 3000 通过 +- [ ] 本机:unpacked 扩展 Chrome 手工加载(用户验收) +- [ ] 测试账号登录扩展 → 保存 example → `/vault` 可见条目 + `sessions/…json` +- [ ] share-to-ai 后 `mnote-vault login --id` → `mode=session` +- [x] 未登录 content 禁用保存(仅提示去 Options) +- [ ] `TESTING_REFERENCE.md` 段落 + +### PR-F — P1 增强(可另 PR) + +- [x] 同站 accounts 合并 + 每账号 session(扩展 MATCH_URL + append mode) +- [ ] workbench 复制连接信息 +- [ ] 登录成功后二次抓 cookie +- [ ] `resolve --field session` + +--- + +## 11. 验收场景(手工 / smoke) + +| # | 场景 | 期望 | +|---|------|------| +| S1 | 无配置打开 example.com 登录表 | 无保存弹层或仅「去连接」 | +| S2 | 配置错误 baseUrl | 测试连接失败,不存 token | +| S3 | 正确登录 + rootUri | badge 已连接 | +| S4 | 填用户名密码 → 点保存 → 确认(默认保存登录态) | create 成功;`sessions/{id}/primary.json` 存在;password reveal 正确 | +| S5 | 取消「同时保存登录态」 | 仅 credential,无新 session 文件 | +| S6 | 同站再存另一账号选「追加」 | accounts 两个;**各有** session 文件(若均勾选) | +| S7 | token 过期后保存 | 401 → 引导重新登录,不写半截 | +| S8 | 勾选同步 AI | AI 本有 credential **+** session;`mnote-vault login` 可复用 | +| S9 | 不勾选同步 AI | 仅人用库有 session;AI `login` 无该 id 或无 cookie | +| S10 | 审计文件 | 有 create / session_put,无明文密码/cookie 行 | +| S11 | skill 文档 | Agent 按 skill 用 `login`/`session`,不扫 vault 文件 | + +--- + +## 12. Alternatives Considered + +| 方案 | 结论 | +|------|------| +| 只做 bookmarklet 调 API | 无可靠 background;token 易进页面;否 | +| 浏览器原生 Password Manager 导出再 import | 不解决「当下保存」;可作迁移旁路 | +| 全部走 Native Host 写磁盘 | 无远程 baseUrl;与用户需求不符;P2 可选 | +| 扩展直接读 `.mnote/vault` 文件 | 违反 vault path deny 与加密演进;禁止 | +| 复用 agent `mnote-vault` token | 权限与 actor 错误;禁止 | +| 继续只嵌 frontmatter `loginSession` | 同站多账号互踩;否;演进为每账号文件 | +| session 只做 attachments 附件 | 路径长、list 难;默认 session 文件,附件作导出旁路 | + +--- + +## 13. 待决问题(实现前钉死;推荐已标) + +| # | 问题 | 推荐默认 | +|---|------|----------| +| D1 | 鉴权 E1 cookie vs E2 Bearer | **E2 Bearer**;dev 可附带 cookie | +| D2 | content_scripts 全站 vs activeTab | **全站侦测 + 用户确认保存** | +| D3 | extension token 是否 reveal | **P0 否**;P2 autofill 再开 | +| D4 | rootUri 如何获得 | **P0 手动 / 工作台复制**;P1 列 directory grant | +| D5 | 默认 folderPath | `imported/browser` | +| D6 | 是否默认 tags | `["from-extension"]` | +| D7 | 扩展目录 monorepo 还是独立 repo | **monorepo `extensions/mnote-vault`** | +| D8 | 发布渠道 | P0 仅 unpacked / 自签;商店上架另案 | +| D9 | 与 12-1「不做自动填表」文案 | 修订交叉引用,避免矛盾 | +| D10 | 密码字段启发式失败 | 工具栏手动选中 / 用户可编辑确认层 | +| D11 | 默认是否保存登录态 | **是(默认勾选)**;用户可取消 | +| D12 | session 存 frontmatter vs 独立文件 | **独立文件** `sessions/{cred}/{acc}.json`;frontmatter 只兼容读 | +| D13 | 无多账号时 accountId | **`primary`** | +| D14 | 登录瞬间 cookie 可能不全 | P0 仍写 + 工具栏「更新登录态」;P1 登录成功二次抓 | + +--- + +## 14. 修订记录 + +| 日期 | 变更 | +|------|------| +| 2026-07-24 | 初稿:Chrome MV3 保存扩展;E2 human token;复用 vault CRUD;与 12-1/12-2 边界钉死 | +| 2026-07-24 | 增补:默认保存登录态;每账号独立 session 文件;PUT session API;skill/agent 调用合同 §6.5 | + +--- + +## 15. 批准后下一步(给实现者) + +1. 产品确认 §13 D1–D5、**D11–D14**(默认可直接开工)。 +2. 先 **PR-B**(token + session 文件 + 鉴权)再 **PR-C**(扩展),避免扩展对接空 API。 +3. 同步 **PR-D** skill/policy,保证 Agent 知道 `login`/`session` 读的是 session 文件。 +4. 不做 autofill 直到 P0 保存闭环(账号 + 登录态)绿 + 安全过目。 +5. 扩展是 **人用录入**;Agent 读密/读登录态 **仍只走** `$mnote-vault` CLI,**不是**扩展通道。 + diff --git a/extensions/mnote-vault/README.md b/extensions/mnote-vault/README.md new file mode 100644 index 00000000..abe10de2 --- /dev/null +++ b/extensions/mnote-vault/README.md @@ -0,0 +1,96 @@ +# MNote Vault · Chrome 扩展(MV3) + +将外站登录/注册账号与浏览器 Cookie 保存到 MNote 密码箱。 + +设计稿:`design/12-vault/process/12-3-chrome-extension-vault-save-v1.md` + +## 安装(unpacked) + +1. 启动 mnote-web(默认 `http://127.0.0.1:3000`)。 +2. Chrome → `chrome://extensions` → 开启「开发者模式」→「加载已解压的扩展程序」。 +3. 选择本目录:`extensions/mnote-vault`。 +4. 打开扩展 **Options**: + - baseUrl:本机 `http://127.0.0.1:3000`,或外网 frp 完整地址(如 `https://www.xxx.nyat.app:44938`,含端口) + - MNote 账号/密码 → **连接并登录** + - **首次外网连接会弹「允许访问该网站」**,必须点允许;否则 Chrome 报 `Failed to fetch`(不是隧道挂了) + - **rootUri 一般不用填**:登录后自动取该账号默认工作区 + (`POST /api/local-folder/workspaces/default` → `…/users//workspaces/my-space`)。 + 仅当要把密码箱写到别的 local_folder 时,在「高级」里覆盖。 +5. 在登录页 **提交** 表单;跳转成功后页面右下角弹出确认层(可显示/隐藏密码核对)。 + 也可点工具栏「保存当前页账号」(优先使用 pending draft)。 +6. **登录态(Cookie)依赖网站访问权限**:v0.2.3 起连接 Options / 点保存时会申请 `http://*/*` + `https://*/*`。 + Chrome 提示「读取和更改所有网站的数据」时必须点允许,否则只能保存账号密码、无法写入 `sessions/`。 + 若仍提示「未捕获到 Cookie」:`chrome://extensions` → MNote Vault → 网站访问权限 → **所有网站**。 +7. **去重(v0.2.3)**:同一站点 + 同一用户名 + 同一密码已保存后,再次登录**不再弹确认层**(仅必要时静默补写 Cookie)。 + 点「取消」会暂时不再询问;勾选「此网站不再询问」后该 origin 永久跳过自动提示(仍可用工具栏手动保存)。 + +## 保存流程(P0–P1) + +对齐 KeePassXC / Bitwarden 思路,**不使用 `webRequest`**: + +```text +content: form submit / 提交按钮 + → PENDING_SAVE(background 内存 + chrome.storage.session) + → SPA 约 1.5s 仍在同 URL:直接弹确认层 + → 整页跳转:tabs.onUpdated status=complete + 同站 hostname 匹配 → OPEN_SAVE_UI_WITH_DRAFT +用户确认 + → SAVE_CREDENTIAL(此时再 cookies.getAll) + → P1:约 2.5s 后二次 PUT session(setTimeout + alarms 备份) +``` + +| 项 | 说明 | +|----|------| +| 主触发 | **submit / 提交按钮**,不是 password blur | +| 凭证跨导航 | background `pendingByTab`,TTL 约 3 分钟 | +| 弹窗时机 | **登录后页面**(或 SPA 同页 fallback) | +| Cookie | 确认保存时抓取;空则提示 + 自动二次捕获 | +| 密码核对 | 确认层密码框可「显示/隐藏」 | +| 鉴权 | `POST /api/auth` 后 `POST /api/vault/extension/token` → Bearer `mnext1.*` | +| 写账号 | `POST /api/vault/items` 或 `PATCH` 追加账号 | +| 写登录态 | `PUT /api/vault/items/{id}/session` → `sessions/{id}/{accountId}.json` | + +### 消息类型(background) + +| type | 作用 | +|------|------| +| `PENDING_SAVE` | content 提交时缓存 draft | +| `GET_PENDING` / `CLEAR_PENDING` / `MARK_PENDING_PROMPTED` | 查询 / 取消 / 防重复弹窗 | +| `PROMPT_PENDING` | 工具栏打开确认层(优先 pending) | +| `OPEN_SAVE_UI` / `OPEN_SAVE_UI_WITH_DRAFT` | background → content 展示 UI | +| `SAVE_CREDENTIAL` | 真正写 vault + session | +| `LIST_FOLDERS` | 拉取已有 `folderPath` 列表 + 上次选用分组(确认层下拉) | +| `UPDATE_SESSION` | 工具栏手动更新 Cookie | + +### 分组(folderPath) + +确认层提供: + +- 下拉:已有分组(来自 vault list 的 `folderPath`,含父路径前缀)+「(无分组)」+「+ 新建分组…」 +- 新建:自由填写名称,支持 `/` 分层(如 `工作/客户`) +- 默认选中:上次保存时选用的分组(`chrome.storage.local.lastFolderPath`) +- **不再**硬编码 `imported/browser` + +## 自测建议 + +1. 普通表单登录(整页跳转)→ 登录后应出现确认层,密码可显示核对。 +2. SPA 登录(URL 不变)→ 约 1.5s 后同页弹层。 +3. 工具栏「保存当前页」:若刚提交过,应预填 pending 密码。 +4. 取消确认层 → pending 清除,不再重复弹。 +5. 保存后 cookie 为空时:提示二次捕获;稍后可用「更新登录态」。 + +```bash +# 后端 token + session 单测 +cd rust && cargo test -p mnote-web --lib routes::vault_extension_token::tests +cd rust && cargo test -p mnote-web --lib routes::vault_store::tests::session_file_put_resolve_and_share_copy + +# API smoke(需 3000 已起 + 测试账号) +node scripts/vault-extension-api-smoke.js +``` + +## 权限 + +- `storage` / `cookies` / `activeTab` / `scripting` / `tabs` / `alarms` +- **无** `webRequest` / `webNavigation`(P0–P1 用 `tabs.onUpdated` 足够) +- `optional_host_permissions`:Options 点击时申请 baseUrl;保存 Cookie 时申请目标站 origin +- 外网带非标端口时 origin 含端口,例如 `https://www.xxx.nyat.app:44938/*` diff --git a/extensions/mnote-vault/background.js b/extensions/mnote-vault/background.js new file mode 100644 index 00000000..a8043f4b --- /dev/null +++ b/extensions/mnote-vault/background.js @@ -0,0 +1,1236 @@ +/** + * MNote Vault extension service worker. + * Holds token + cookies + pending save drafts; content script only sends form fields. + * + * Save flow (KeePassXC-style, no webRequest): + * 1) content submit → PENDING_SAVE (background memory / session) + * 2) SPA same-page → fallback prompt after short delay + * 3) full navigation → tabs.onUpdated complete → OPEN_SAVE_UI_WITH_DRAFT + * 4) user confirms → SAVE_CREDENTIAL (+ cookies at confirm time) + * 5) P1: optional delayed session recapture after save + */ + +import * as api from "./lib/api.js"; +import * as storage from "./lib/storage.js"; + +const MAX_COOKIE_VALUE = 8 * 1024; +/** Pending draft TTL (KeePassXC-like short window across redirects). */ +const PENDING_TTL_MS = 3 * 60 * 1000; +/** Max same-tab redirects before we still allow one prompt (then clear on show). */ +const REDIRECT_ALLOWANCE = 5; +/** SPA: if no navigation, prompt on same page after this delay. */ +const SPA_FALLBACK_MS = 1500; +/** P1: re-capture cookies after credential save (login cookies often land late). */ +const SESSION_RECAPTURE_DELAY_MS = 2500; +const PENDING_STORAGE_KEY = "pendingByTab"; + +/** + * In-memory pending drafts: tabId → PendingDraft + * Mirrored to chrome.storage.session when available (SW restart). + * @type {Map} + */ +const pendingByTab = new Map(); +/** @type {Map>} */ +const spaFallbackTimers = new Map(); +/** + * @typedef {object} PendingDraft + * @property {string} username + * @property {string} password + * @property {string} [email] + * @property {string} pageUrl + * @property {string} origin + * @property {string} hostname + * @property {string} title + * @property {number} createdAt + * @property {number} expiresAt + * @property {number} redirectCount + * @property {boolean} [prompted] + * @property {string} [promptedUrl] URL where confirm UI was last shown + * @property {string} [submitUrl] URL at submit time (SPA vs full nav) + * @property {boolean} [fromSubmit] + */ + +chrome.runtime.onInstalled.addListener(() => { + storage.loadConfig().then((cfg) => storage.setBadge(cfg.connected, cfg.email)); + hydratePendingFromSession().catch(() => {}); +}); + +chrome.runtime.onStartup.addListener(() => { + storage.loadConfig().then((cfg) => storage.setBadge(cfg.connected, cfg.email)); + hydratePendingFromSession().catch(() => {}); +}); + +// Restore pending map ASAP when SW wakes. +hydratePendingFromSession().catch(() => {}); + +/** + * @param {string} pageUrl + * @returns {{ origin: string, hostname: string }} + */ +function parseUrlParts(pageUrl) { + try { + const u = new URL(pageUrl); + return { origin: u.origin, hostname: u.hostname.toLowerCase() }; + } catch { + return { origin: "", hostname: "" }; + } +} + +/** + * Same-site check for post-login redirects (e.g. login.example.com → www.example.com). + * Host equal, or one is a subdomain of the other, or same eTLD+1-ish parent. + * @param {string} a + * @param {string} b + */ +function sameSiteHost(a, b) { + if (!a || !b) return false; + const x = a.toLowerCase().replace(/^\./, ""); + const y = b.toLowerCase().replace(/^\./, ""); + if (x === y) return true; + if (x.endsWith("." + y) || y.endsWith("." + x)) return true; + const px = x.split("."); + const py = y.split("."); + if (px.length >= 2 && py.length >= 2) { + const rx = px.slice(-2).join("."); + const ry = py.slice(-2).join("."); + if (rx === ry && rx.includes(".")) return true; + } + return false; +} + +function isExpired(draft) { + return !draft || !draft.expiresAt || Date.now() > draft.expiresAt; +} + +/** + * Persist pending map for SW restarts (session storage only — never local/sync). + */ +async function persistPendingMap() { + if (!chrome.storage?.session) return; + /** @type {Record} */ + const obj = {}; + for (const [tabId, draft] of pendingByTab.entries()) { + if (!isExpired(draft)) obj[String(tabId)] = draft; + } + try { + await chrome.storage.session.set({ [PENDING_STORAGE_KEY]: obj }); + } catch (e) { + console.warn("[mnote-vault] persist pending failed", e); + } +} + +async function hydratePendingFromSession() { + if (!chrome.storage?.session) return; + try { + const data = await chrome.storage.session.get(PENDING_STORAGE_KEY); + const obj = data?.[PENDING_STORAGE_KEY] || {}; + for (const [k, draft] of Object.entries(obj)) { + const tabId = Number(k); + if (!Number.isFinite(tabId) || isExpired(draft)) continue; + pendingByTab.set(tabId, draft); + } + } catch (e) { + console.warn("[mnote-vault] hydrate pending failed", e); + } +} + +/** + * @param {number} tabId + */ +function clearSpaFallback(tabId) { + const t = spaFallbackTimers.get(tabId); + if (t) { + clearTimeout(t); + spaFallbackTimers.delete(tabId); + } +} + +/** + * @param {number} tabId + * @param {boolean} [persist] + */ +async function clearPending(tabId, persist = true) { + clearSpaFallback(tabId); + pendingByTab.delete(tabId); + if (persist) await persistPendingMap(); +} + +/** + * @param {number} tabId + * @param {object} fields + * @returns {PendingDraft} + */ +function buildPendingDraft(tabId, fields) { + const pageUrl = String(fields.pageUrl || "").trim(); + const { origin, hostname } = parseUrlParts(pageUrl); + const now = Date.now(); + const prev = pendingByTab.get(tabId); + // Fresh submit always resets prompted so a new login can show again. + const isFreshSubmit = fields.fromSubmit !== false && fields.resetPrompted !== false; + /** @type {PendingDraft} */ + const draft = { + username: String(fields.username || prev?.username || "").trim(), + password: String(fields.password || prev?.password || ""), + email: String(fields.email || prev?.email || "").trim() || undefined, + pageUrl: pageUrl || prev?.pageUrl || "", + origin: origin || prev?.origin || "", + hostname: hostname || prev?.hostname || "", + title: String(fields.title || prev?.title || "").trim(), + createdAt: prev?.createdAt || now, + expiresAt: now + PENDING_TTL_MS, + redirectCount: isFreshSubmit ? 0 : prev?.redirectCount || 0, + prompted: isFreshSubmit ? false : Boolean(prev?.prompted), + promptedUrl: isFreshSubmit ? undefined : prev?.promptedUrl, + submitUrl: isFreshSubmit + ? pageUrl || prev?.submitUrl || "" + : prev?.submitUrl || pageUrl || "", + fromSubmit: fields.fromSubmit !== false, + }; + return draft; +} + +/** + * @param {number} tabId + */ +async function markPendingPrompted(tabId, promptedUrl) { + const draft = pendingByTab.get(tabId); + if (!draft || isExpired(draft)) return false; + draft.prompted = true; + if (promptedUrl) draft.promptedUrl = promptedUrl; + pendingByTab.set(tabId, draft); + clearSpaFallback(tabId); + await persistPendingMap(); + return true; +} + +/** + * @param {number} tabId + * @param {PendingDraft} draft + * @param {{ spaFallback?: boolean }} [opts] + */ +async function setPending(tabId, draft, opts = {}) { + if (!draft.password && !draft.username) return null; + pendingByTab.set(tabId, draft); + await persistPendingMap(); + + clearSpaFallback(tabId); + if (opts.spaFallback !== false) { + const timer = setTimeout(() => { + spaFallbackTimers.delete(tabId); + const cur = pendingByTab.get(tabId); + if (!cur || isExpired(cur) || cur.prompted) return; + // SPA only if still on submit URL; full nav is handled by tabs.onUpdated. + chrome.tabs + .get(tabId) + .then((tab) => { + const cur2 = pendingByTab.get(tabId); + if (!cur2 || isExpired(cur2) || cur2.prompted) return null; + const tabUrl = tab?.url || ""; + if ( + tabUrl && + cur2.submitUrl && + tabUrl !== cur2.submitUrl && + /^https?:/i.test(tabUrl) + ) { + // Already navigated — prefer post-login prompt path. + return maybePromptPendingOnComplete(tabId, tabUrl); + } + return openSaveUiOnTab(tabId, cur2, { reason: "spa_fallback" }); + }) + .catch((e) => + console.warn("[mnote-vault] spa fallback prompt failed", e) + ); + }, SPA_FALLBACK_MS); + spaFallbackTimers.set(tabId, timer); + } + return draft; +} + +/** + * @param {number} tabId + * @param {PendingDraft} draft + * @param {{ reason?: string, force?: boolean }} [opts] + */ +async function openSaveUiOnTab(tabId, draft, opts = {}) { + if (!draft || isExpired(draft)) { + await clearPending(tabId); + return { ok: false, error: "pending expired" }; + } + if (draft.prompted && !opts.force) { + return { ok: true, skipped: true, reason: "already_prompted" }; + } + + let tabUrl = draft.pageUrl; + try { + const tab = await chrome.tabs.get(tabId); + if (tab?.url) tabUrl = tab.url; + } catch { + /* ignore */ + } + + // Dedupe: already saved same site+user+password → silent session update or skip. + if (!opts.force) { + try { + let decision = await storage.shouldSuppressPrompt({ + pageUrl: draft.pageUrl || tabUrl, + username: draft.username, + password: draft.password, + }); + // Local memory may be empty (first install / other device). Fall back to vault. + if (!decision.suppress && draft.password) { + const vaultDecision = await matchVaultSameCredential({ + pageUrl: draft.pageUrl || tabUrl, + username: draft.username, + password: draft.password, + }); + if (vaultDecision) { + decision = vaultDecision; + // Seed local memory so later prompts are free. + try { + await storage.rememberSavedSite({ + pageUrl: draft.pageUrl || tabUrl, + username: draft.username, + password: draft.password, + credentialId: vaultDecision.credentialId, + hasSession: !vaultDecision.needSessionOnly, + }); + } catch { + /* ignore */ + } + } + } + if (decision.suppress) { + if ( + (decision.reason === "already_saved_same_password" || + decision.reason === "vault_same_password") && + decision.credentialId + ) { + // Silent session-only refresh when missing session, or best-effort refresh. + if (decision.needSessionOnly !== false) { + try { + const cfg = await storage.loadConfig(); + if (cfg.connected) { + await ensureHostPermission(cfg.baseUrl, tabUrl || draft.pageUrl); + const cap = await captureCookies(tabUrl || draft.pageUrl); + if (cap.cookieHeader || (cap.cookies && cap.cookies.length)) { + await api.putSession(cfg, decision.credentialId, { + accountId: decision.accountId || "primary", + cookieHeader: cap.cookieHeader, + cookies: cap.cookies, + origin: cap.origin, + }); + await storage.rememberSavedSite({ + pageUrl: draft.pageUrl || tabUrl, + username: draft.username, + password: draft.password, + credentialId: decision.credentialId, + hasSession: true, + }); + console.info( + "[mnote-vault] silent session update ok", + decision.credentialId + ); + } + } + } catch (e) { + console.warn("[mnote-vault] silent session update failed", e); + } + } + } + draft.prompted = true; + draft.promptedUrl = tabUrl; + pendingByTab.set(tabId, draft); + await persistPendingMap(); + clearSpaFallback(tabId); + // Drop pending so further redirects do not re-evaluate. + await clearPending(tabId); + return { + ok: true, + skipped: true, + reason: decision.reason || "deduped", + credentialId: decision.credentialId, + }; + } + } catch (e) { + console.warn("[mnote-vault] dedupe check failed", e); + } + } + + draft.prompted = true; + draft.promptedUrl = tabUrl; + pendingByTab.set(tabId, draft); + await persistPendingMap(); + clearSpaFallback(tabId); + + const payload = { + type: "OPEN_SAVE_UI_WITH_DRAFT", + draft: { + pageUrl: draft.pageUrl || tabUrl, + title: draft.title, + username: draft.username, + password: draft.password, + email: draft.email || "", + fromPending: true, + pendingReason: opts.reason || "pending", + }, + }; + + try { + await chrome.tabs.sendMessage(tabId, payload); + return { ok: true, reason: opts.reason || "pending" }; + } catch (e) { + // Content script may not be ready yet — retry once shortly. + await new Promise((r) => setTimeout(r, 400)); + try { + await chrome.tabs.sendMessage(tabId, payload); + return { ok: true, reason: opts.reason || "pending", retried: true }; + } catch (e2) { + // Allow another attempt on next complete if injection still fails. + draft.prompted = false; + draft.promptedUrl = undefined; + pendingByTab.set(tabId, draft); + await persistPendingMap(); + console.warn("[mnote-vault] OPEN_SAVE_UI_WITH_DRAFT failed", e2); + return { ok: false, error: e2?.message || String(e2) }; + } + } +} + +/** + * After full navigation completes: same tab + same site → show confirm with draft. + * @param {number} tabId + * @param {string} tabUrl + */ +async function maybePromptPendingOnComplete(tabId, tabUrl) { + const draft = pendingByTab.get(tabId); + if (!draft || isExpired(draft)) { + if (draft) await clearPending(tabId); + return; + } + + const { hostname } = parseUrlParts(tabUrl || ""); + if (hostname && draft.hostname && !sameSiteHost(hostname, draft.hostname)) { + // Navigated away to unrelated site — drop pending. + await clearPending(tabId); + return; + } + + // If we already showed UI on this exact URL, do not spam. + if (draft.prompted && draft.promptedUrl && tabUrl && draft.promptedUrl === tabUrl) { + return; + } + // After a successful prompt on the login URL, do NOT re-open on every + // post-login hop (that is the main "keeps asking" UX bug). User can still + // open popup manually if the first prompt was closed accidentally. + if (draft.prompted) return; + + draft.redirectCount = (draft.redirectCount || 0) + 1; + if (draft.redirectCount > REDIRECT_ALLOWANCE) { + await clearPending(tabId); + return; + } + // Prefer post-login URL for matching / cookie capture display. + if (tabUrl && /^https?:/i.test(tabUrl)) { + draft.pageUrl = tabUrl; + const parts = parseUrlParts(tabUrl); + if (parts.origin) draft.origin = parts.origin; + if (parts.hostname) draft.hostname = parts.hostname; + } + pendingByTab.set(tabId, draft); + await persistPendingMap(); + + await openSaveUiOnTab(tabId, draft, { reason: "nav_complete" }); +} + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status !== "complete") return; + const url = tab?.url || changeInfo.url || ""; + if (!url || !/^https?:/i.test(url)) return; + if (!pendingByTab.has(tabId)) return; + maybePromptPendingOnComplete(tabId, url).catch((e) => + console.warn("[mnote-vault] pending onUpdated failed", e) + ); +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + clearPending(tabId).catch(() => {}); +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (!alarm?.name?.startsWith("mnote-vault-recapture:")) return; + // name: mnote-vault-recapture:: + // pageUrl stored via chrome.storage.session under same key for SW restart. + const raw = alarm.name.slice("mnote-vault-recapture:".length); + runSessionRecaptureFromAlarm(raw).catch((e) => + console.warn("[mnote-vault] session recapture failed", e) + ); +}); + +/** + * Ensure host permission for baseUrl (+ optional page origin for cookies). + * Prefer broad http(s) origins when available so cookies.getAll works on any site. + * @param {string} baseUrl + * @param {string} [pageUrl] + * @param {{ broad?: boolean }} [opts] + */ +async function ensureHostPermission(baseUrl, pageUrl, opts = {}) { + const origins = []; + // Broad grant: required to read cookies on arbitrary login sites. + if (opts.broad !== false) { + origins.push("http://*/*", "https://*/*"); + } + try { + if (baseUrl) { + const b = new URL(api.normalizeBaseUrl(baseUrl)); + origins.push(`${b.origin}/*`); + } + } catch { + /* ignore */ + } + if (pageUrl) { + try { + const p = new URL(pageUrl); + origins.push(`${p.origin}/*`); + } catch { + /* ignore */ + } + } + // de-dupe + const uniq = [...new Set(origins.filter(Boolean))]; + if (!uniq.length) return true; + try { + const have = await chrome.permissions.contains({ origins: uniq }); + if (have) return true; + // service worker 内 request 常无用户手势而失败;保存确认层应在 content 点击手势内先申请。 + return await chrome.permissions.request({ origins: uniq }); + } catch { + // Fallback: try page origin only (narrower permission dialog). + if (pageUrl) { + try { + const p = new URL(pageUrl); + const narrow = [`${p.origin}/*`]; + if (baseUrl) { + try { + narrow.push(`${new URL(api.normalizeBaseUrl(baseUrl)).origin}/*`); + } catch { + /* ignore */ + } + } + const haveN = await chrome.permissions.contains({ origins: narrow }); + if (haveN) return true; + return await chrome.permissions.request({ origins: narrow }); + } catch { + return false; + } + } + return false; + } +} + +/** + * Whether extension currently has host access for cookies on pageUrl. + * @param {string} pageUrl + */ +async function hasCookieHostAccess(pageUrl) { + try { + const broad = await chrome.permissions.contains({ + origins: ["http://*/*", "https://*/*"], + }); + if (broad) return true; + if (!pageUrl) return false; + const origin = new URL(pageUrl).origin; + return await chrome.permissions.contains({ origins: [`${origin}/*`] }); + } catch { + return false; + } +} + +/** + * Server-side dedupe: same origin + same username + same password already in vault. + * Uses list + reveal (extension token has view/edit scopes). + * @param {{ pageUrl: string, username?: string, password?: string }} draft + * @returns {Promise} + */ +async function matchVaultSameCredential(draft) { + const password = String(draft.password || ""); + if (!password) return null; + const cfg = await storage.loadConfig(); + if (!cfg.connected) return null; + try { + await ensureHostPermission(cfg.baseUrl, undefined, { broad: false }); + const listed = await api.listVault(cfg); + const items = + listed?.result?.items || listed?.items || listed?.result || []; + const arr = Array.isArray(items) ? items : []; + const hit = api.findMatchingCredential(arr, draft.pageUrl, draft.username); + if (!hit?.id) return null; + + // Prefer matching account slot when multi-account. + let accountId; + if (Array.isArray(hit.accounts) && hit.accounts.length) { + const want = String(draft.username || "") + .trim() + .toLowerCase(); + const acc = + hit.accounts.find((a) => { + const u = String(a?.username || a?.email || "") + .trim() + .toLowerCase(); + return want && u === want; + }) || hit.accounts[0]; + accountId = acc?.id ? String(acc.id) : undefined; + } + + const revealed = await api.revealField(cfg, hit.id, { + field: "password", + accountId, + }); + // Response shape: { result: { secret: { state, value } } } or unwrapped. + const body = revealed?.result || revealed; + const plain = + body?.secret?.value ?? + body?.value ?? + body?.password ?? + body?.field?.value ?? + ""; + if (!plain || String(plain) !== password) { + // Different / missing password for same site/user → still prompt. + return null; + } + const hasSession = Boolean( + hit.hasLoginSession || + (Array.isArray(hit.accounts) && + hit.accounts.some((a) => a?.hasLoginSession)) + ); + return { + suppress: true, + reason: "vault_same_password", + credentialId: String(hit.id), + accountId: accountId || "primary", + needSessionOnly: !hasSession, + }; + } catch (e) { + console.warn("[mnote-vault] vault dedupe failed", e); + return null; + } +} + +/** + * Capture cookies for page origin (background only). + * Prefer domain-wide getAll so path/subdomain session cookies are not missed. + * Requires host permission for the page origin (see manifest host_permissions). + * @param {string} pageUrl + */ +async function captureCookies(pageUrl) { + let origin; + let hostname = ""; + try { + const u = new URL(pageUrl); + origin = u.origin; + hostname = u.hostname || ""; + } catch { + origin = undefined; + } + + /** @type {chrome.cookies.Cookie[]} */ + let list = []; + try { + // domain query covers all paths under host; url-only often misses sibling paths. + if (hostname) { + list = await chrome.cookies.getAll({ domain: hostname }); + // also include parent-domain cookies (e.g. .heyunidc.cn) when host is www. + const parts = hostname.split(".").filter(Boolean); + if (parts.length >= 2) { + const parent = parts.slice(-2).join("."); + if (parent && parent !== hostname) { + const extra = await chrome.cookies.getAll({ domain: parent }); + list = list.concat(extra); + } + } + } + if ((!list || list.length === 0) && origin) { + list = await chrome.cookies.getAll({ url: origin + "/" }); + } + if ((!list || list.length === 0) && pageUrl) { + list = await chrome.cookies.getAll({ url: pageUrl }); + } + } catch (e) { + console.warn("[mnote-vault] cookies.getAll failed", e); + return { + cookieHeader: "", + cookies: [], + count: 0, + origin, + error: e?.message || String(e), + }; + } + + // de-dupe by name+domain+path + const seen = new Set(); + const cookies = []; + const parts = []; + for (const c of list || []) { + const key = `${c.name}\0${c.domain}\0${c.path}`; + if (seen.has(key)) continue; + seen.add(key); + let value = c.value || ""; + if (value.length > MAX_COOKIE_VALUE) { + value = value.slice(0, MAX_COOKIE_VALUE); + } + cookies.push({ + name: c.name, + value, + domain: c.domain, + path: c.path, + secure: c.secure, + httpOnly: c.httpOnly, + sameSite: c.sameSite, + expirationDate: c.expirationDate, + }); + parts.push(`${c.name}=${value}`); + } + return { + cookieHeader: parts.join("; "), + cookies, + count: cookies.length, + origin, + }; +} + +/** + * @param {string} alarmKey credentialId:accountId (pageUrl in session storage) + */ +async function runSessionRecaptureFromAlarm(alarmKey) { + const storeKey = `recapture:${alarmKey}`; + let job = null; + try { + if (chrome.storage?.session) { + const data = await chrome.storage.session.get(storeKey); + job = data?.[storeKey] || null; + await chrome.storage.session.remove(storeKey); + } + } catch { + /* ignore */ + } + if (!job?.credentialId || !job?.pageUrl) return; + const { credentialId, accountId = "primary", pageUrl } = job; + const cfg = await storage.loadConfig(); + if (!cfg.connected) return; + await ensureHostPermission(cfg.baseUrl, pageUrl); + const cap = await captureCookies(pageUrl); + if (!cap.cookieHeader && !(cap.cookies && cap.cookies.length)) { + console.info( + "[mnote-vault] session recapture: still no cookies", + credentialId + ); + return; + } + await api.putSession(cfg, credentialId, { + accountId, + cookieHeader: cap.cookieHeader, + cookies: cap.cookies, + origin: cap.origin, + }); + console.info("[mnote-vault] session recapture ok", credentialId); +} + +/** + * P1: after save, wait for session cookies then PUT again (alarms survive SW sleep). + * Note: Chrome may clamp delayInMinutes to ~1 min minimum in practice. + * @param {object} opts + * @param {string} opts.credentialId + * @param {string} opts.accountId + * @param {string} opts.pageUrl + * @param {number} [opts.delayMs] + */ +async function scheduleSessionRecapture(opts) { + const { + credentialId, + accountId = "primary", + pageUrl, + delayMs = SESSION_RECAPTURE_DELAY_MS, + } = opts; + if (!credentialId || !pageUrl) return; + const alarmKey = `${credentialId}:${accountId}`; + const storeKey = `recapture:${alarmKey}`; + try { + if (chrome.storage?.session) { + await chrome.storage.session.set({ + [storeKey]: { credentialId, accountId, pageUrl }, + }); + } + } catch (e) { + console.warn("[mnote-vault] recapture store failed", e); + } + // Prefer short setTimeout while SW is warm; also schedule alarm as backup. + setTimeout(() => { + runSessionRecaptureFromAlarm(alarmKey).catch((e) => + console.warn("[mnote-vault] session recapture failed", e) + ); + }, delayMs); + try { + const delayInMinutes = Math.max(delayMs / 60000, 0.05); + chrome.alarms.create(`mnote-vault-recapture:${alarmKey}`, { + delayInMinutes, + }); + } catch { + /* alarms optional */ + } +} + +/** + * @param {object} payload + * @param {string} payload.pageUrl + * @param {string} payload.title + * @param {string} payload.username + * @param {string} payload.password + * @param {string} [payload.email] + * @param {boolean} [payload.saveSession] + * @param {boolean} [payload.shareToAi] + * @param {string} [payload.mode] create | append + * @param {string} [payload.existingId] + * @param {number} [payload.tabId] + * @param {boolean} [payload.recaptureSession] + */ +async function saveCredential(payload) { + const cfg = await storage.loadConfig(); + if (!cfg.connected) { + throw new Error("未连接密码箱:请先在扩展 Options 用账号密码连接"); + } + await ensureHostPermission(cfg.baseUrl, payload.pageUrl); + + let credentialId = payload.existingId || null; + let accountId = "primary"; + + if (payload.mode === "append" && payload.existingId) { + const got = await api.getItem(cfg, payload.existingId); + const item = got?.result?.item || got?.item || got?.result; + const accounts = Array.isArray(item?.accounts) ? [...item.accounts] : []; + const newId = `acc_${Date.now().toString(36)}`; + accounts.push({ + id: newId, + label: "from-extension", + username: payload.username || "", + password: payload.password || "", + email: payload.email || undefined, + }); + await api.updateItem(cfg, payload.existingId, { accounts }); + credentialId = payload.existingId; + accountId = newId; + } else { + const title = + (payload.title || "").trim() || + (() => { + try { + return new URL(payload.pageUrl).hostname; + } catch { + return "Saved credential"; + } + })(); + // folderPath:用户显式选择(含空=未分组)优先;否则上次选用 + const folderPath = + payload.folderPath !== undefined && payload.folderPath !== null + ? storage.normalizeFolderPath(payload.folderPath) + : storage.normalizeFolderPath(cfg.lastFolderPath) || ""; + const created = await api.createItem(cfg, { + title, + url: payload.pageUrl, + username: payload.username || "", + password: payload.password || "", + email: payload.email || undefined, + tags: ["from-extension"], + ...(folderPath ? { folderPath } : {}), + notesMarkdown: "Saved via MNote Vault extension", + }); + // 记住本次分组,下次确认层默认选中 + await storage.saveLastFolderPath(folderPath); + const item = created?.result?.item || created?.item; + credentialId = item?.id; + if (!credentialId) { + throw new Error("创建条目失败:响应无 id"); + } + // 与服务端账号槽 id 对齐;无 accounts 时仍用 primary 文件名 + const firstAcc = Array.isArray(item?.accounts) && item.accounts[0]; + if (firstAcc && firstAcc.id) { + accountId = String(firstAcc.id); + } + } + + let sessionResult = null; + let sessionError = null; + let sessionSaved = false; + const wantSession = payload.saveSession !== false; + if (wantSession) { + try { + // Prefer host grant granted earlier from content click gesture. + if (payload.hostPermissionGranted) { + /* already requested in content */ + } else { + await ensureHostPermission(cfg.baseUrl, payload.pageUrl, { + broad: true, + }); + } + const hasHost = await hasCookieHostAccess(payload.pageUrl); + const cap = await captureCookies(payload.pageUrl); + if (cap.cookieHeader || (cap.cookies && cap.cookies.length)) { + sessionResult = await api.putSession(cfg, credentialId, { + accountId, + cookieHeader: cap.cookieHeader, + cookies: cap.cookies, + origin: cap.origin, + }); + sessionSaved = true; + } else { + const detail = cap.error ? `:${cap.error}` : ""; + if (!hasHost) { + sessionError = + `未授权读取此站 Cookie${detail}。请在保存前允许「网站访问权限→所有网站」,或点扩展详情手动开启。`; + } else { + sessionError = + `未捕获到 Cookie${detail}(将在数秒后自动重试;登录 Cookie 可能尚未写入)`; + } + } + } catch (e) { + sessionError = e?.message || String(e); + } + + // P1: always schedule one delayed recapture when user asked for session. + if (payload.recaptureSession !== false) { + await scheduleSessionRecapture({ + credentialId, + accountId, + pageUrl: payload.pageUrl, + }); + } + } + + let shareResult = null; + if (payload.shareToAi) { + try { + shareResult = await api.shareToAi(cfg, credentialId); + } catch (e) { + shareResult = { error: e?.message || String(e) }; + } + } + + // Remember save for auto-prompt dedupe (same origin+user+password). + try { + await storage.rememberSavedSite({ + pageUrl: payload.pageUrl, + username: payload.username, + password: payload.password, + credentialId, + hasSession: sessionSaved, + }); + } catch (e) { + console.warn("[mnote-vault] rememberSavedSite failed", e); + } + + // Clear pending for this tab after successful save. + if (payload.tabId != null) { + await clearPending(payload.tabId); + } + + return { + ok: true, + credentialId, + accountId, + sessionSaved, + sessionResult: sessionResult?.result || sessionResult, + sessionError, + shareResult: shareResult?.result || shareResult, + sessionRecaptureScheduled: wantSession && payload.recaptureSession !== false, + }; +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + handleMessage(message, sender) + .then((result) => sendResponse({ ok: true, result })) + .catch((err) => + sendResponse({ + ok: false, + error: err?.message || String(err), + code: err?.code, + }) + ); + return true; // async +}); + +/** + * @param {object} message + * @param {chrome.runtime.MessageSender} [sender] + */ +async function handleMessage(message, sender) { + const type = message?.type; + const senderTabId = sender?.tab?.id; + + switch (type) { + case "GET_STATUS": { + const cfg = await storage.loadConfig(); + await storage.setBadge(cfg.connected, cfg.email); + return { + connected: cfg.connected, + baseUrl: cfg.baseUrl, + rootUri: cfg.rootUri, + email: cfg.email, + userId: cfg.userId, + hasToken: Boolean(cfg.token), + }; + } + case "CONNECT": { + const baseUrl = api.normalizeBaseUrl(message.baseUrl); + const email = String(message.email || message.account || "").trim(); + const password = String(message.password || ""); + if (!email || !password) { + throw new Error("需要 MNote 账号与密码"); + } + const okPerm = await ensureHostPermission(baseUrl); + if (!okPerm) { + throw new Error( + `需要允许扩展访问 ${new URL(baseUrl).origin}(请在 Options 点连接时允许权限弹窗;或 chrome://extensions → 扩展详情 → 网站访问权限)` + ); + } + try { + await api.signIn(baseUrl, { email, password }); + } catch (e) { + const m = e?.message || String(e); + if (/Failed to fetch|NetworkError|Load failed/i.test(m)) { + throw new Error( + `无法连接 ${baseUrl}:${m}。请确认外网隧道可达,且已允许扩展访问该 origin(含端口)` + ); + } + throw e; + } + + // 默认:用账号固定 my-space;仅当用户显式填了 file:// 才覆盖 + let rootUri = String(message.rootUri || "").trim(); + let rootPath; + let workspaceSource = "override"; + if (!rootUri || rootUri === "auto") { + const ws = await api.ensureDefaultWorkspace(baseUrl, { + credentials: true, + }); + rootUri = ws.rootUri; + rootPath = ws.rootPath; + workspaceSource = "default"; + } else if (!rootUri.startsWith("file://")) { + throw new Error("rootUri 若填写必须是 file:// 本地工作区路径"); + } + + const issued = await api.issueExtensionToken(baseUrl, { + email, + extensionId: chrome.runtime.id, + ttlHours: message.ttlHours ?? 168, + }); + const result = issued?.result || issued; + const token = result?.token; + if (!token || !String(token).startsWith("mnext1.")) { + throw new Error("签发 extension token 失败"); + } + await storage.savePersistent({ + baseUrl, + rootUri, + rootPath: rootPath || undefined, + userId: result.userId || email, + email: result.email || email, + connectedAt: new Date().toISOString(), + }); + await storage.saveSession({ + token, + tokenExpiresAt: result.expiresAt, + jti: result.jti, + }); + await storage.setBadge(true, result.email || email); + return { + connected: true, + userId: result.userId, + email: result.email || email, + expiresAt: result.expiresAt, + rootUri, + rootPath, + workspaceSource, + }; + } + case "DISCONNECT": { + await storage.clearSession(); + await storage.setBadge(false); + return { connected: false }; + } + case "MATCH_URL": { + const cfg = await storage.loadConfig(); + if (!cfg.connected) return { connected: false, items: [] }; + await ensureHostPermission(cfg.baseUrl); + const listed = await api.listVault(cfg); + const items = + listed?.result?.items || listed?.items || listed?.result || []; + const arr = Array.isArray(items) ? items : []; + return { + connected: true, + items: api.matchItemsByUrl(arr, message.pageUrl || "").slice(0, 5), + }; + } + case "LIST_FOLDERS": { + const cfg = await storage.loadConfig(); + if (!cfg.connected) { + return { connected: false, folders: [], lastFolderPath: "" }; + } + await ensureHostPermission(cfg.baseUrl); + const listed = await api.listVault(cfg); + const items = + listed?.result?.items || listed?.items || listed?.result || []; + const arr = Array.isArray(items) ? items : []; + return { + connected: true, + folders: api.collectFolderPaths(arr), + lastFolderPath: cfg.lastFolderPath || "", + }; + } + case "ENSURE_PAGE_HOST": { + const pageUrl = message.pageUrl || ""; + const cfg = await storage.loadConfig(); + // Prefer broad http(s) so future sites work without another prompt. + const ok = await ensureHostPermission(cfg.baseUrl, pageUrl, { + broad: message.broad !== false, + }); + const hasHost = await hasCookieHostAccess(pageUrl); + return { ok, pageUrl, hasHost }; + } + case "DISMISS_SAVE": { + const pageUrl = message.pageUrl || ""; + const username = message.username || ""; + const forever = Boolean(message.forever); + await storage.rememberDismissedSite({ pageUrl, username, forever }); + const tabId = message.tabId ?? senderTabId; + if (tabId != null) await clearPending(tabId); + return { ok: true, forever }; + } + case "CHECK_DEDUPE": { + return storage.shouldSuppressPrompt({ + pageUrl: message.pageUrl || "", + username: message.username || "", + password: message.password || "", + }); + } + case "PENDING_SAVE": { + const tabId = message.tabId ?? senderTabId; + if (tabId == null) throw new Error("PENDING_SAVE 需要 tabId"); + const draft = buildPendingDraft(tabId, { + username: message.username ?? message.payload?.username, + password: message.password ?? message.payload?.password, + email: message.email ?? message.payload?.email, + pageUrl: message.pageUrl ?? message.payload?.pageUrl, + title: message.title ?? message.payload?.title, + fromSubmit: message.fromSubmit !== false, + }); + if (!draft.password) { + throw new Error("PENDING_SAVE 需要 password"); + } + await setPending(tabId, draft, { + spaFallback: message.spaFallback !== false, + }); + return { + ok: true, + tabId, + expiresAt: draft.expiresAt, + hostname: draft.hostname, + }; + } + case "GET_PENDING": { + const tabId = message.tabId ?? senderTabId; + if (tabId == null) return { pending: null }; + const draft = pendingByTab.get(tabId); + if (!draft || isExpired(draft)) { + if (draft) await clearPending(tabId); + return { pending: null }; + } + return { + pending: { + username: draft.username, + password: draft.password, + email: draft.email || "", + pageUrl: draft.pageUrl, + title: draft.title, + hostname: draft.hostname, + expiresAt: draft.expiresAt, + redirectCount: draft.redirectCount, + prompted: Boolean(draft.prompted), + }, + }; + } + case "CLEAR_PENDING": { + const tabId = message.tabId ?? senderTabId; + if (tabId != null) await clearPending(tabId); + return { cleared: true }; + } + case "MARK_PENDING_PROMPTED": { + const tabId = message.tabId ?? senderTabId; + if (tabId == null) return { marked: false }; + const marked = await markPendingPrompted( + tabId, + message.promptedUrl || sender?.tab?.url || message.pageUrl + ); + return { marked }; + } + case "PROMPT_PENDING": { + // Force-open confirm from popup / toolbar using pending or empty draft. + const tabId = message.tabId ?? senderTabId; + if (tabId == null) throw new Error("需要 tabId"); + let draft = pendingByTab.get(tabId); + if (draft && isExpired(draft)) { + await clearPending(tabId); + draft = undefined; + } + if (draft) { + return openSaveUiOnTab(tabId, draft, { + reason: "manual_prompt", + force: true, + }); + } + // No pending: ask content to open UI from live form / empty. + try { + await chrome.tabs.sendMessage(tabId, { type: "OPEN_SAVE_UI" }); + return { ok: true, reason: "live_form" }; + } catch (e) { + throw new Error( + e?.message || + "无法打开确认层(受保护页面或 content script 未加载)" + ); + } + } + case "SAVE_CREDENTIAL": { + const payload = { ...(message.payload || message) }; + if (payload.tabId == null && senderTabId != null) { + payload.tabId = senderTabId; + } + return saveCredential(payload); + } + case "UPDATE_SESSION": { + const cfg = await storage.loadConfig(); + if (!cfg.connected) throw new Error("未连接"); + const pageUrl = message.pageUrl; + const id = message.credentialId || message.id; + if (!id || !pageUrl) throw new Error("需要 credentialId 与 pageUrl"); + await ensureHostPermission(cfg.baseUrl, pageUrl); + const cap = await captureCookies(pageUrl); + if (!cap.cookieHeader && !(cap.cookies && cap.cookies.length)) { + throw new Error("未捕获到 Cookie"); + } + const res = await api.putSession(cfg, id, { + accountId: message.accountId || "primary", + cookieHeader: cap.cookieHeader, + cookies: cap.cookies, + origin: cap.origin, + }); + return res?.result || res; + } + case "CAPTURE_COOKIE_COUNT": { + const pageUrl = message.pageUrl; + if (!pageUrl) return { count: 0 }; + await ensureHostPermission("", pageUrl); + const cap = await captureCookies(pageUrl); + return { count: cap.count, origin: cap.origin, error: cap.error || null }; + } + default: + throw new Error(`未知消息类型: ${type}`); + } +} diff --git a/extensions/mnote-vault/content.js b/extensions/mnote-vault/content.js new file mode 100644 index 00000000..c0a19fce --- /dev/null +++ b/extensions/mnote-vault/content.js @@ -0,0 +1,655 @@ +/** + * Content script: detect password form submit, confirm UI, message background. + * Does NOT hold tokens or cookie secrets. + * + * KeePassXC-style flow: + * - submit / submit-button click → PENDING_SAVE (background) + * - after navigation, background opens OPEN_SAVE_UI_WITH_DRAFT + * - SPA same-page: background spa fallback also opens draft UI + * - blur is NOT a primary save trigger + */ + +(() => { + if (window.__mnoteVaultContentInstalled) return; + window.__mnoteVaultContentInstalled = true; + + const HOST_ID = "mnote-vault-save-host"; + /** Last known password on this document only (lost on full navigation). */ + let lastPassword = ""; + let lastUsername = ""; + let lastEmail = ""; + /** Prevent double PENDING_SAVE from submit + button click. */ + let submitLockUntil = 0; + const SUBMIT_LOCK_MS = 1200; + + function findUsernameNear(passwordInput) { + const form = + passwordInput?.form || passwordInput?.closest?.("form") || document; + const candidates = form.querySelectorAll( + 'input[type="email"], input[type="text"], input[name*="user" i], input[name*="login" i], input[name*="email" i], input[autocomplete="username"], input[autocomplete="email"]' + ); + for (const el of candidates) { + if (el === passwordInput) continue; + if (el.type === "password" || el.type === "hidden") continue; + const v = (el.value || "").trim(); + if (v) return { username: v, email: el.type === "email" ? v : "" }; + } + // Fallback: any non-empty text-like input in form + const more = form.querySelectorAll( + "input:not([type]), input[type=text], input[type=email], input[type=tel]" + ); + for (const el of more) { + if (el.type === "password" || el.type === "hidden") continue; + const v = (el.value || "").trim(); + if (v) return { username: v, email: el.type === "email" ? v : "" }; + } + return { username: "", email: "" }; + } + + function findPasswordIn(root) { + const scope = root || document; + const list = scope.querySelectorAll?.('input[type="password"]') || []; + for (const el of list) { + if (el.value && el.value.length >= 1) return el; + } + return list[0] || null; + } + + function isRegisterPath() { + const p = location.pathname.toLowerCase(); + return /sign[-_]?up|register|signup|join|create[-_]?account/.test(p); + } + + function buildTitle() { + let title = (document.title || "").slice(0, 80); + if (isRegisterPath() && title && !/注册|register|sign.?up/i.test(title)) { + title = `注册 · ${title}`; + } + return title || location.hostname; + } + + function collectDraftFromDom(preferPasswordInput) { + const pwEl = + preferPasswordInput || + (document.activeElement?.type === "password" + ? document.activeElement + : null) || + findPasswordIn(document); + const pw = (pwEl && pwEl.value) || lastPassword || ""; + const near = pwEl + ? findUsernameNear(pwEl) + : { username: lastUsername, email: lastEmail }; + const username = (near.username || lastUsername || "").trim(); + const email = (near.email || lastEmail || "").trim(); + if (pw) lastPassword = pw; + if (username) lastUsername = username; + if (email) lastEmail = email; + return { + pageUrl: location.href, + title: buildTitle(), + username, + email, + password: pw, + }; + } + + function removeHost() { + const el = document.getElementById(HOST_ID); + if (el) el.remove(); + } + + function sendPending(draft) { + if (!draft?.password) return; + const now = Date.now(); + if (now < submitLockUntil) return; + submitLockUntil = now + SUBMIT_LOCK_MS; + + lastPassword = draft.password; + if (draft.username) lastUsername = draft.username; + if (draft.email) lastEmail = draft.email; + + chrome.runtime.sendMessage( + { + type: "PENDING_SAVE", + pageUrl: draft.pageUrl || location.href, + title: draft.title || buildTitle(), + username: draft.username || "", + password: draft.password, + email: draft.email || "", + fromSubmit: true, + }, + () => { + // ignore response; SW may be waking + void chrome.runtime.lastError; + } + ); + } + + /** + * @param {object} draft + * @param {{ fromPending?: boolean }} [opts] + */ + async function showConfirm(draft, opts = {}) { + removeHost(); + const host = document.createElement("div"); + host.id = HOST_ID; + host.style.cssText = + "all:initial;position:fixed;z-index:2147483646;right:16px;bottom:16px;font-family:system-ui,sans-serif;"; + const shadow = host.attachShadow({ mode: "closed" }); + + const style = document.createElement("style"); + style.textContent = ` + .card { + width: 320px; max-width: 92vw; + background: #1a1d23; color: #e8eaed; + border: 1px solid #3a3f4b; border-radius: 12px; + box-shadow: 0 12px 40px rgba(0,0,0,.45); + padding: 14px 14px 12px; font-size: 13px; line-height: 1.4; + } + h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; } + label { display:block; margin: 8px 0 2px; color:#9aa0a6; font-size:11px; } + input[type=text], input[type=password] { + width: 100%; box-sizing: border-box; + background:#0f1115; color:#e8eaed; border:1px solid #3a3f4b; + border-radius:6px; padding:6px 8px; font-size:13px; + } + .pw-wrap { + display:flex; align-items:stretch; gap:0; + border:1px solid #3a3f4b; border-radius:6px; overflow:hidden; + background:#0f1115; + } + .pw-wrap input { + flex:1; min-width:0; border:0; border-radius:0; padding:6px 8px; + } + .pw-wrap input:focus { outline:none; } + .btn-pw-toggle { + flex:0 0 auto; min-width:52px; margin:0; border:0; border-left:1px solid #3a3f4b; + border-radius:0; background:#2a2f3a; color:#e8eaed; padding:0 10px; + font-size:11px; cursor:pointer; line-height:1; + } + .btn-pw-toggle:hover { background:#343b49; } + .btn-pw-toggle:focus-visible { outline:2px solid #3b82f6; outline-offset:-2px; } + .row { display:flex; gap:8px; align-items:center; margin-top:8px; font-size:12px; } + .row input { width:auto; } + .actions { display:flex; gap:8px; justify-content:flex-end; margin-top:12px; } + button { + border:0; border-radius:8px; padding:7px 12px; cursor:pointer; font-size:12px; + } + .btn-cancel { background:#2a2f3a; color:#e8eaed; } + .btn-save { background:#3b82f6; color:#fff; font-weight:600; } + .btn-save:disabled { opacity:.5; cursor:wait; } + .msg { margin-top:8px; font-size:11px; color:#9aa0a6; min-height:14px; } + .msg.err { color:#f87171; } + .msg.ok { color:#4ade80; } + select { + width:100%; box-sizing:border-box; background:#0f1115; color:#e8eaed; + border:1px solid #3a3f4b; border-radius:6px; padding:6px 8px; + } + .muted { color:#9aa0a6; font-size:11px; margin-top:4px; } + .banner { + margin: 0 0 8px; padding: 6px 8px; border-radius: 6px; + background: #243044; color: #93c5fd; font-size: 11px; + } + .folder-new { + display:none; margin-top:6px; + } + .folder-new.is-open { display:block; } + .folder-hint { margin-top:4px; } + `; + + const fromPending = Boolean(draft.fromPending || opts.fromPending); + const card = document.createElement("div"); + card.className = "card"; + card.innerHTML = ` +

保存到 MNote 密码箱

+ ${ + fromPending + ? `` + : "" + } + + + + + + + +
+ + +
+ + +
+ + +
+
读取已有分组…
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+ `; + + shadow.appendChild(style); + shadow.appendChild(card); + document.documentElement.appendChild(host); + + const $ = (id) => shadow.getElementById(id); + $("title").value = draft.title || buildTitle(); + $("url").value = draft.pageUrl || location.href; + $("username").value = draft.username || lastUsername || ""; + $("password").value = draft.password || lastPassword || ""; + + function syncFolderNewVisibility() { + const isNew = $("folder").value === "__new__"; + $("folderNewWrap").classList.toggle("is-open", isNew); + if (isNew) { + try { + $("folderNew").focus(); + } catch { + /* ignore */ + } + } + } + + function resolveFolderPath() { + const sel = $("folder").value; + if (sel === "__new__") { + return String($("folderNew").value || "").trim(); + } + return String(sel || "").trim(); + } + + $("folder").onchange = syncFolderNewVisibility; + + $("togglePw").onclick = () => { + const input = $("password"); + const btn = $("togglePw"); + const show = input.type === "password"; + input.type = show ? "text" : "password"; + btn.textContent = show ? "隐藏" : "显示"; + btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码"); + }; + + // status + match + chrome.runtime.sendMessage({ type: "GET_STATUS" }, (st) => { + void chrome.runtime.lastError; + const r = st?.result || st; + if (!st?.ok || !r?.connected) { + $("msg").className = "msg err"; + $("msg").textContent = "未连接:请打开扩展 Options 登录 MNote"; + $("save").disabled = true; + } + }); + + const matchUrl = $("url").value.trim() || location.href; + chrome.runtime.sendMessage( + { type: "MATCH_URL", pageUrl: matchUrl }, + (res) => { + void chrome.runtime.lastError; + const items = res?.result?.items || []; + const sel = $("match"); + for (const it of items) { + const opt = document.createElement("option"); + opt.value = it.id; + opt.textContent = `追加到 · ${it.title || it.id}`; + sel.appendChild(opt); + } + } + ); + + // 已有分组 + 上次选用 + chrome.runtime.sendMessage({ type: "LIST_FOLDERS" }, (res) => { + void chrome.runtime.lastError; + const r = res?.result || res || {}; + const folders = Array.isArray(r.folders) ? r.folders : []; + const last = String(r.lastFolderPath || "").trim(); + const sel = $("folder"); + // 保留「无分组」「新建」两端选项,中间插入已有路径 + const newOpt = sel.querySelector('option[value="__new__"]'); + for (const fp of folders) { + const opt = document.createElement("option"); + opt.value = fp; + opt.textContent = fp; + sel.insertBefore(opt, newOpt); + } + if (last) { + // 上次路径若不在列表中,也加一条再选中 + let found = false; + for (const o of sel.options) { + if (o.value === last) { + found = true; + break; + } + } + if (!found) { + const opt = document.createElement("option"); + opt.value = last; + opt.textContent = last; + sel.insertBefore(opt, newOpt); + } + sel.value = last; + } else { + sel.value = ""; + } + syncFolderNewVisibility(); + $("folderHint").textContent = folders.length + ? `共 ${folders.length} 个已有分组;可选已有或新建` + : "暂无已有分组,可选择「新建分组」并填写名称"; + }); + + // Request broad host access so cookies.getAll can see session cookies. + // Best-effort here; the critical grant happens on Save click (user gesture). + chrome.runtime.sendMessage( + { type: "ENSURE_PAGE_HOST", pageUrl: matchUrl, broad: true }, + () => { + void chrome.runtime.lastError; + chrome.runtime.sendMessage( + { type: "CAPTURE_COOKIE_COUNT", pageUrl: matchUrl }, + (res) => { + void chrome.runtime.lastError; + const n = res?.result?.count ?? 0; + const err = res?.result?.error; + if (n > 0) { + $("cookieHint").textContent = `将捕获约 ${n} 条 Cookie`; + } else if (err) { + $("cookieHint").textContent = + `无法读取 Cookie:${err}。点「保存」时会再次申请网站权限;也可在扩展详情设为「所有网站」。`; + } else { + $("cookieHint").textContent = + "当前可能尚无会话 Cookie。点「保存」时会申请 Cookie 权限并捕获;若仍为 0,请检查扩展网站访问权限。"; + } + } + ); + } + ); + + $("cancel").onclick = () => { + const forever = Boolean($("neverAsk")?.checked); + chrome.runtime.sendMessage( + { + type: "DISMISS_SAVE", + pageUrl: $("url").value.trim() || location.href, + username: $("username").value.trim(), + forever, + }, + () => { + void chrome.runtime.lastError; + removeHost(); + } + ); + }; + + $("save").onclick = async () => { + $("save").disabled = true; + $("msg").className = "msg"; + $("msg").textContent = "保存中…"; + const matchVal = $("match").value; + const folderPath = resolveFolderPath(); + if ($("folder").value === "__new__" && !folderPath) { + $("msg").className = "msg err"; + $("msg").textContent = "请填写新分组名称"; + $("save").disabled = false; + return; + } + + // Critical: request cookie host permission in this click gesture. + // Without it, chrome.cookies.getAll returns [] and login session cannot be saved. + let hostPermissionGranted = false; + try { + hostPermissionGranted = await new Promise((resolve) => { + chrome.runtime.sendMessage( + { + type: "ENSURE_PAGE_HOST", + pageUrl: $("url").value.trim() || location.href, + broad: true, + }, + (res) => { + void chrome.runtime.lastError; + resolve(Boolean(res?.ok || res?.result?.ok || res?.result?.hasHost)); + } + ); + }); + } catch { + hostPermissionGranted = false; + } + + const payload = { + pageUrl: $("url").value.trim() || location.href, + title: $("title").value.trim(), + username: $("username").value.trim(), + password: $("password").value, + email: draft.email || lastEmail || "", + folderPath, + saveSession: $("saveSession").checked, + shareToAi: $("shareAi").checked, + mode: matchVal === "create" ? "create" : "append", + existingId: matchVal === "create" ? null : matchVal, + recaptureSession: true, + hostPermissionGranted, + }; + chrome.runtime.sendMessage( + { type: "SAVE_CREDENTIAL", payload }, + (res) => { + void chrome.runtime.lastError; + if (!res?.ok) { + $("msg").className = "msg err"; + $("msg").textContent = res?.error || "保存失败"; + $("save").disabled = false; + return; + } + const r = res.result || {}; + let text = `已保存 ${r.credentialId || ""}`; + if (folderPath && matchVal === "create") text += ` · 分组 ${folderPath}`; + if (r.sessionError) text += ` · 登录态: ${r.sessionError}`; + else if (payload.saveSession && r.sessionSaved) text += " · 含登录态"; + else if (payload.saveSession) text += " · 登录态待二次捕获"; + if (r.sessionRecaptureScheduled && !r.sessionSaved) { + text += " · 将二次捕获 Cookie"; + } + $("msg").className = "msg ok"; + $("msg").textContent = text; + setTimeout(removeHost, r.sessionError ? 3200 : 1800); + } + ); + }; + } + + /** + * Manual toolbar open: prefer pending draft, else live form / last known. + */ + function openFromManual() { + chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => { + void chrome.runtime.lastError; + const pending = res?.result?.pending; + if (pending?.password) { + showConfirm( + { + pageUrl: pending.pageUrl || location.href, + title: pending.title || buildTitle(), + username: pending.username || "", + email: pending.email || "", + password: pending.password, + fromPending: true, + pendingReason: "manual", + }, + { fromPending: true } + ); + return; + } + const draft = collectDraftFromDom(); + showConfirm(draft); + }); + } + + function onSubmit(e) { + const form = e.target; + if (!(form instanceof HTMLFormElement)) return; + const pw = findPasswordIn(form) || findPasswordIn(document); + if (!pw || !pw.value) return; + const draft = collectDraftFromDom(pw); + if (!draft.password) return; + // Do not block navigation; stash in background immediately. + sendPending(draft); + } + + /** + * Capture click on submit-like controls (including formless login UIs). + */ + function onPointerDownCapture(e) { + const t = e.target; + if (!(t instanceof Element)) return; + const btn = t.closest( + 'button[type="submit"], input[type="submit"], button:not([type]), [type="submit"], [role="button"]' + ); + if (!btn) return; + // Avoid random buttons far from any password field. + const form = btn.closest("form"); + const pw = + (form && findPasswordIn(form)) || + findPasswordIn(btn.closest("div,section,main,body") || document); + if (!pw || !pw.value || pw.value.length < 1) return; + // Heuristic: prefer buttons that look like login/submit. + const label = ( + btn.getAttribute("aria-label") || + btn.value || + btn.textContent || + "" + ) + .trim() + .toLowerCase(); + const looksSubmit = + btn.matches('button[type="submit"], input[type="submit"], [type="submit"]') || + /log\s*in|sign\s*in|sign\s*up|register|提交|登录|注册|continue|next|进入/.test( + label + ) || + Boolean(form); + if (!looksSubmit) return; + const draft = collectDraftFromDom(pw); + if (draft.password) sendPending(draft); + } + + // Remember password as user types (still only in this document). + function onPasswordInput(e) { + const t = e.target; + if (!(t instanceof HTMLInputElement) || t.type !== "password") return; + if (t.value) lastPassword = t.value; + const near = findUsernameNear(t); + if (near.username) lastUsername = near.username; + if (near.email) lastEmail = near.email; + } + + document.addEventListener("submit", onSubmit, true); + document.addEventListener("pointerdown", onPointerDownCapture, true); + document.addEventListener("input", onPasswordInput, true); + + chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => { + if (msg?.type === "OPEN_SAVE_UI_WITH_DRAFT") { + const d = msg.draft || {}; + showConfirm( + { + pageUrl: d.pageUrl || location.href, + title: d.title || buildTitle(), + username: d.username || lastUsername || "", + email: d.email || lastEmail || "", + password: d.password || lastPassword || "", + fromPending: true, + pendingReason: d.pendingReason || "pending", + }, + { fromPending: true } + ); + sendResponse({ ok: true }); + return false; + } + if (msg?.type === "OPEN_SAVE_UI") { + openFromManual(); + sendResponse({ ok: true }); + return false; + } + return false; + }); + + // After full navigation inject: if background still has pending for this tab + // and did not manage to message us yet, pull once (belt-and-suspenders). + function pullPendingOnLoad() { + // Let tabs.onUpdated / OPEN_SAVE_UI_WITH_DRAFT win the race first. + setTimeout(() => { + if (document.getElementById(HOST_ID)) return; + chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => { + void chrome.runtime.lastError; + const p = res?.result?.pending; + if (!p?.password || p.prompted) return; + if (document.getElementById(HOST_ID)) return; + // Dedupe before content-side pull opens UI (same site already saved). + chrome.runtime.sendMessage( + { + type: "CHECK_DEDUPE", + pageUrl: p.pageUrl || location.href, + username: p.username || "", + password: p.password || "", + }, + (dedupeRes) => { + void chrome.runtime.lastError; + const d = dedupeRes?.result || dedupeRes; + if (d?.suppress) { + chrome.runtime.sendMessage({ type: "CLEAR_PENDING" }, () => { + void chrome.runtime.lastError; + }); + return; + } + if (document.getElementById(HOST_ID)) return; + showConfirm( + { + pageUrl: p.pageUrl || location.href, + title: p.title || buildTitle(), + username: p.username || "", + email: p.email || "", + password: p.password, + fromPending: true, + pendingReason: "content_pull", + }, + { fromPending: true } + ); + chrome.runtime.sendMessage( + { + type: "MARK_PENDING_PROMPTED", + promptedUrl: location.href, + }, + () => { + void chrome.runtime.lastError; + } + ); + } + ); + }); + }, 700); + } + + if (document.readyState === "complete") { + pullPendingOnLoad(); + } else { + window.addEventListener("load", pullPendingOnLoad, { once: true }); + } +})(); diff --git a/extensions/mnote-vault/icons/icon128.png b/extensions/mnote-vault/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..527388cac56fabcd9a093e36cc14651e13cab875 GIT binary patch literal 343 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV3hN8aSW-r_4bY1r8zN20dzj{1O8;C{y4`?@p^d?TfrEijfq|ugL4bjY49@=7>mQumd*wMQ^Ki0#(642J;;`7@L9NKum)>$S4T-!25u~aEDR*$!6;ikcg+N KpUXO@geCy%QXg3W literal 0 HcmV?d00001 diff --git a/extensions/mnote-vault/lib/api.js b/extensions/mnote-vault/lib/api.js new file mode 100644 index 00000000..0c3a0a4d --- /dev/null +++ b/extensions/mnote-vault/lib/api.js @@ -0,0 +1,416 @@ +/** + * mnote-web vault API client (background only). + * Token / secrets never enter content scripts. + */ + +/** + * @typedef {object} VaultConfig + * @property {string} baseUrl + * @property {string} rootUri + * @property {string} [token] + * @property {string} [userId] + * @property {string} [email] + */ + +/** + * @param {string} baseUrl + * @returns {string} + */ +export function normalizeBaseUrl(baseUrl) { + const raw = String(baseUrl || "").trim().replace(/\/+$/, ""); + if (!raw) throw new Error("baseUrl 不能为空"); + let u; + try { + u = new URL(raw); + } catch { + throw new Error("baseUrl 不是合法 URL"); + } + if (u.protocol !== "http:" && u.protocol !== "https:") { + throw new Error("baseUrl 仅支持 http/https"); + } + return u.origin + (u.pathname === "/" ? "" : u.pathname.replace(/\/+$/, "")); +} + +/** + * @param {string} baseUrl + * @param {string} path + * @param {object} [opts] + * @param {string} [opts.token] + * @param {string} [opts.method] + * @param {object} [opts.body] + * @param {boolean} [opts.credentials] + */ +export async function vaultFetch(baseUrl, path, opts = {}) { + const base = normalizeBaseUrl(baseUrl); + const url = `${base}${path.startsWith("/") ? path : `/${path}`}`; + /** @type {Record} */ + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + }; + if (opts.token) { + headers.Authorization = `Bearer ${opts.token}`; + } + const init = { + method: opts.method || (opts.body ? "POST" : "GET"), + headers, + credentials: opts.credentials ? "include" : "omit", + }; + if (opts.body !== undefined) { + init.body = JSON.stringify(opts.body); + } + let res; + try { + res = await fetch(url, init); + } catch (e) { + const err = new Error( + `Failed to fetch ${url}: ${e?.message || e}(若是扩展请求,先确认已授权该 origin 的 host 权限)` + ); + err.cause = e; + err.code = "network_error"; + throw err; + } + let data = null; + const text = await res.text(); + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!res.ok) { + const code = data?.code || data?.error || `http_${res.status}`; + const msg = + data?.message || data?.error || text || `${res.status} ${res.statusText}`; + const err = new Error(String(msg)); + err.code = code; + err.status = res.status; + err.body = data; + throw err; + } + return data; +} + +/** + * Auth sign-in (sets session cookies when credentials:include). + * @param {string} baseUrl + * @param {{ email: string, password: string }} creds + */ +export async function signIn(baseUrl, creds) { + const email = String(creds.email || "").trim(); + const password = String(creds.password || ""); + const name = email.includes("@") ? email.split("@")[0] : email; + // 与 mnote-web /auth 页、scripts/mnote-vault-cli.js 对齐 + return vaultFetch(baseUrl, "/api/auth", { + method: "POST", + credentials: true, + body: { + action: "auth:signIn", + args: { + provider: "password", + params: { + password, + flow: "signIn", + account: email, + email, + name, + }, + }, + }, + }); +} + +/** + * Issue E2 extension token (requires session cookie from signIn). + * @param {string} baseUrl + * @param {object} [opts] + */ +export async function issueExtensionToken(baseUrl, opts = {}) { + return vaultFetch(baseUrl, "/api/vault/extension/token", { + method: "POST", + credentials: true, + body: { + clientId: "chrome-extension", + extensionId: opts.extensionId || null, + ttlHours: opts.ttlHours ?? 168, + email: opts.email || null, + }, + }); +} + +/** + * 当前登录账号的默认 local_folder 工作区(每用户固定 my-space)。 + * POST /api/local-folder/workspaces/default — 不存在则创建。 + * @param {string} baseUrl + * @param {{ token?: string, credentials?: boolean }} [opts] + * @returns {Promise<{ rootUri: string, rootPath?: string }>} + */ +export async function ensureDefaultWorkspace(baseUrl, opts = {}) { + const data = await vaultFetch( + baseUrl, + "/api/local-folder/workspaces/default", + { + method: "POST", + credentials: opts.credentials !== false && !opts.token, + token: opts.token, + body: {}, + } + ); + const ws = data?.workspace || data?.result?.workspace || data?.result || data; + const rootUri = String(ws?.rootUri || "").trim(); + if (!rootUri.startsWith("file://")) { + throw new Error( + "无法解析默认工作区 rootUri(请确认账号已登录且 mnote-web 可用)" + ); + } + return { + rootUri, + rootPath: ws?.rootPath ? String(ws.rootPath) : undefined, + workspaceId: ws?.manifest?.workspaceId || ws?.workspaceId, + }; +} + +/** + * @param {VaultConfig} cfg + */ +export async function whoami(cfg) { + return vaultFetch(cfg.baseUrl, "/api/auth/whoami", { + method: "GET", + token: cfg.token, + credentials: !cfg.token, + }); +} + +/** + * @param {VaultConfig} cfg + */ +export async function listVault(cfg) { + const q = new URLSearchParams({ + rootUri: cfg.rootUri, + sourceKind: "local_folder", + }); + return vaultFetch(cfg.baseUrl, `/api/vault/list?${q}`, { + method: "GET", + token: cfg.token, + }); +} + +/** + * @param {VaultConfig} cfg + * @param {object} item + */ +export async function createItem(cfg, item) { + return vaultFetch(cfg.baseUrl, "/api/vault/items", { + method: "POST", + token: cfg.token, + body: { + rootUri: cfg.rootUri, + sourceKind: "local_folder", + ...item, + }, + }); +} + +/** + * @param {VaultConfig} cfg + * @param {string} id + * @param {object} patch + */ +export async function updateItem(cfg, id, patch) { + return vaultFetch(cfg.baseUrl, `/api/vault/items/${encodeURIComponent(id)}`, { + method: "PATCH", + token: cfg.token, + body: { + rootUri: cfg.rootUri, + sourceKind: "local_folder", + ...patch, + }, + }); +} + +/** + * @param {VaultConfig} cfg + * @param {string} id + */ +export async function getItem(cfg, id) { + const q = new URLSearchParams({ + rootUri: cfg.rootUri, + sourceKind: "local_folder", + }); + return vaultFetch( + cfg.baseUrl, + `/api/vault/items/${encodeURIComponent(id)}?${q}`, + { + method: "GET", + token: cfg.token, + } + ); +} + +/** + * Reveal a secret field (password etc.) for dedupe / autofill decisions. + * @param {VaultConfig} cfg + * @param {string} id + * @param {{ field?: string, accountId?: string, secretId?: string }} [opts] + */ +export async function revealField(cfg, id, opts = {}) { + return vaultFetch( + cfg.baseUrl, + `/api/vault/items/${encodeURIComponent(id)}/reveal`, + { + method: "POST", + token: cfg.token, + body: { + rootUri: cfg.rootUri, + sourceKind: "local_folder", + field: opts.field || "password", + accountId: opts.accountId, + secretId: opts.secretId, + }, + } + ); +} + +/** + * PUT session file for credential/account. + * @param {VaultConfig} cfg + * @param {string} id + * @param {object} session + */ +export async function putSession(cfg, id, session) { + return vaultFetch( + cfg.baseUrl, + `/api/vault/items/${encodeURIComponent(id)}/session`, + { + method: "PUT", + token: cfg.token, + body: { + rootUri: cfg.rootUri, + sourceKind: "local_folder", + source: "chrome_extension", + ...session, + }, + } + ); +} + +/** + * @param {VaultConfig} cfg + * @param {string} id + * @param {object} [opts] + */ +export async function shareToAi(cfg, id, opts = {}) { + return vaultFetch( + cfg.baseUrl, + `/api/vault/items/${encodeURIComponent(id)}/share-to-ai`, + { + method: "POST", + token: cfg.token, + body: { + rootUri: cfg.rootUri, + sourceKind: "local_folder", + ...opts, + }, + } + ); +} + +/** + * 从 list 条目收集唯一 folderPath(含父路径前缀)。 + * @param {Array} items + * @returns {string[]} + */ +export function collectFolderPaths(items) { + /** @type {Record} */ + const set = {}; + const list = Array.isArray(items) ? items : []; + for (const it of list) { + const fp = String(it?.folderPath || "").trim(); + if (!fp) continue; + set[fp] = true; + const parts = fp.split("/").filter(Boolean); + for (let i = 1; i < parts.length; i++) { + set[parts.slice(0, i).join("/")] = true; + } + } + return Object.keys(set).sort((a, b) => a.localeCompare(b, "zh")); +} + +/** + * Match list items by origin (client-side filter). + * @param {Array} items + * @param {string} pageUrl + */ +export function matchItemsByUrl(items, pageUrl) { + let origin = ""; + let hostname = ""; + try { + const u = new URL(pageUrl); + origin = u.origin; + hostname = u.hostname || ""; + } catch { + return []; + } + const list = Array.isArray(items) ? items : []; + const hostOf = (raw) => { + try { + return new URL(String(raw)).hostname || ""; + } catch { + return ""; + } + }; + const sameSite = (a, b) => { + if (!a || !b) return false; + if (a === b) return true; + return a.endsWith(`.${b}`) || b.endsWith(`.${a}`); + }; + return list + .filter((it) => { + if (it && it.status && String(it.status).toLowerCase() === "deleted") { + return false; + } + const urls = []; + if (it.url) urls.push(String(it.url)); + if (Array.isArray(it.urls)) urls.push(...it.urls.map(String)); + return urls.some((u) => { + try { + const ou = new URL(u); + if (ou.origin === origin) return true; + return sameSite(ou.hostname, hostname); + } catch { + const bare = origin.replace(/^https?:\/\//, ""); + return String(u).includes(bare) || sameSite(hostOf(u), hostname); + } + }); + }) + .sort((a, b) => + String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")) + ); +} + +/** + * Find a list item that matches page origin + username (best effort). + * @param {Array} items + * @param {string} pageUrl + * @param {string} [username] + */ +export function findMatchingCredential(items, pageUrl, username) { + const matched = matchItemsByUrl(items, pageUrl); + if (!matched.length) return null; + const want = String(username || "") + .trim() + .toLowerCase(); + if (!want) return matched[0]; + const byUser = matched.find((it) => { + const candidates = [ + it.username, + it.email, + ...(Array.isArray(it.accounts) + ? it.accounts.flatMap((a) => [a?.username, a?.email]) + : []), + ] + .filter(Boolean) + .map((s) => String(s).trim().toLowerCase()); + return candidates.includes(want); + }); + return byUser || matched[0]; +} diff --git a/extensions/mnote-vault/lib/storage.js b/extensions/mnote-vault/lib/storage.js new file mode 100644 index 00000000..f1801293 --- /dev/null +++ b/extensions/mnote-vault/lib/storage.js @@ -0,0 +1,291 @@ +/** + * Persistent config (sync/local) vs session token. + */ + +const SYNC_KEYS = [ + "baseUrl", + "rootUri", + "rootPath", + "userId", + "email", + "connectedAt", + /** 上次保存选用的分组路径,扩展确认层默认选中 */ + "lastFolderPath", + /** + * 去重记忆(不跨浏览器配置同步,仅 local): + * - savedSites: { [origin]: { [usernameKey]: { credentialId, passwordFp, savedAt, hasSession? } } } + * - dismissedSites: { [origin]: { [usernameKey]: dismissedAt } + * - neverAskOrigins: { [origin]: true } + */ + "savedSites", + "dismissedSites", + "neverAskOrigins", +]; +const SESSION_KEYS = ["token", "tokenExpiresAt", "jti"]; + +/** Normalize username for dedupe keys. */ +export function normalizeUsernameKey(username) { + return String(username || "") + .trim() + .toLowerCase(); +} + +/** + * Lightweight non-cryptographic fingerprint for password equality checks. + * Not a security hash — only used to avoid re-prompting the same login. + * @param {string} password + */ +export function passwordFingerprint(password) { + const s = String(password || ""); + // FNV-1a 32-bit + length, enough for local dedupe. + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return `fp_${(h >>> 0).toString(16)}_${s.length}`; +} + +/** + * @param {string} pageUrl + * @returns {string} origin or "" + */ +export function originFromUrl(pageUrl) { + try { + return new URL(String(pageUrl || "")).origin; + } catch { + return ""; + } +} + +/** + * @returns {Promise<{savedSites:object, dismissedSites:object, neverAskOrigins:object}>} + */ +export async function loadDedupeState() { + const data = await chrome.storage.local.get([ + "savedSites", + "dismissedSites", + "neverAskOrigins", + ]); + return { + savedSites: + data.savedSites && typeof data.savedSites === "object" + ? data.savedSites + : {}, + dismissedSites: + data.dismissedSites && typeof data.dismissedSites === "object" + ? data.dismissedSites + : {}, + neverAskOrigins: + data.neverAskOrigins && typeof data.neverAskOrigins === "object" + ? data.neverAskOrigins + : {}, + }; +} + +/** + * Remember a successful save so the same site+user+password does not re-prompt. + * @param {{ pageUrl: string, username?: string, password?: string, credentialId?: string, hasSession?: boolean }} info + */ +export async function rememberSavedSite(info) { + const origin = originFromUrl(info.pageUrl); + if (!origin) return; + const userKey = normalizeUsernameKey(info.username) || "_"; + const state = await loadDedupeState(); + const byOrigin = { ...(state.savedSites[origin] || {}) }; + byOrigin[userKey] = { + credentialId: info.credentialId || byOrigin[userKey]?.credentialId || "", + passwordFp: passwordFingerprint(info.password || ""), + savedAt: new Date().toISOString(), + hasSession: Boolean(info.hasSession), + }; + const savedSites = { ...state.savedSites, [origin]: byOrigin }; + // Clear dismiss for this user after explicit save. + const dismissed = { ...(state.dismissedSites[origin] || {}) }; + delete dismissed[userKey]; + const dismissedSites = { ...state.dismissedSites }; + if (Object.keys(dismissed).length) dismissedSites[origin] = dismissed; + else delete dismissedSites[origin]; + await chrome.storage.local.set({ savedSites, dismissedSites }); +} + +/** + * User cancelled save prompt for this site+username (suppress for a while). + * @param {{ pageUrl: string, username?: string, forever?: boolean }} info + */ +export async function rememberDismissedSite(info) { + const origin = originFromUrl(info.pageUrl); + if (!origin) return; + if (info.forever) { + const state = await loadDedupeState(); + await chrome.storage.local.set({ + neverAskOrigins: { ...state.neverAskOrigins, [origin]: true }, + }); + return; + } + const userKey = normalizeUsernameKey(info.username) || "_"; + const state = await loadDedupeState(); + const byOrigin = { ...(state.dismissedSites[origin] || {}) }; + byOrigin[userKey] = Date.now(); + await chrome.storage.local.set({ + dismissedSites: { ...state.dismissedSites, [origin]: byOrigin }, + }); +} + +/** + * Decide whether auto-prompt should be suppressed for this login draft. + * @param {{ pageUrl: string, username?: string, password?: string }} draft + * @param {{ dismissTtlMs?: number }} [opts] + * @returns {Promise<{ suppress: boolean, reason?: string, credentialId?: string, needSessionOnly?: boolean }>} + */ +export async function shouldSuppressPrompt(draft, opts = {}) { + const origin = originFromUrl(draft.pageUrl); + if (!origin) return { suppress: false }; + const userKey = normalizeUsernameKey(draft.username) || "_"; + const state = await loadDedupeState(); + if (state.neverAskOrigins[origin]) { + return { suppress: true, reason: "never_ask_origin" }; + } + const dismissTtlMs = opts.dismissTtlMs ?? 14 * 24 * 60 * 60 * 1000; + const dismissedAt = state.dismissedSites?.[origin]?.[userKey]; + if (typeof dismissedAt === "number" && Date.now() - dismissedAt < dismissTtlMs) { + return { suppress: true, reason: "recently_dismissed" }; + } + const saved = state.savedSites?.[origin]?.[userKey]; + if (saved && draft.password) { + const fp = passwordFingerprint(draft.password); + if (saved.passwordFp === fp) { + // Same account+password already saved: do not re-ask for credential. + // If session was never captured, allow a silent session-only update path. + return { + suppress: true, + reason: "already_saved_same_password", + credentialId: saved.credentialId || "", + needSessionOnly: !saved.hasSession, + }; + } + } + return { suppress: false }; +} + +/** + * @returns {Promise<{baseUrl?:string, rootUri?:string, userId?:string, email?:string, connectedAt?:string, lastFolderPath?:string}>} + */ +export async function loadPersistent() { + return chrome.storage.local.get(SYNC_KEYS); +} + +/** + * @param {object} data + */ +export async function savePersistent(data) { + /** @type {Record} */ + const patch = {}; + for (const k of SYNC_KEYS) { + if (data[k] !== undefined) patch[k] = data[k]; + } + await chrome.storage.local.set(patch); +} + +/** + * @returns {Promise<{token?:string, tokenExpiresAt?:string, jti?:string}>} + */ +export async function loadSession() { + // Prefer session storage (cleared on browser restart) when available. + if (chrome.storage.session) { + return chrome.storage.session.get(SESSION_KEYS); + } + return chrome.storage.local.get(SESSION_KEYS); +} + +/** + * @param {object} data + */ +export async function saveSession(data) { + /** @type {Record} */ + const patch = {}; + for (const k of SESSION_KEYS) { + if (data[k] !== undefined) patch[k] = data[k]; + } + if (chrome.storage.session) { + await chrome.storage.session.set(patch); + } else { + await chrome.storage.local.set(patch); + } +} + +export async function clearSession() { + if (chrome.storage.session) { + await chrome.storage.session.remove(SESSION_KEYS); + } + await chrome.storage.local.remove(SESSION_KEYS); +} + +/** + * Full runtime config for API calls. + * @returns {Promise<{baseUrl:string, rootUri:string, token?:string, userId?:string, email?:string, connected:boolean}>} + */ +export async function loadConfig() { + const [p, s] = await Promise.all([loadPersistent(), loadSession()]); + const baseUrl = (p.baseUrl || "").trim(); + const rootUri = (p.rootUri || "").trim(); + const token = (s.token || "").trim(); + const connected = Boolean(baseUrl && rootUri && token); + return { + baseUrl, + rootUri, + token: token || undefined, + userId: p.userId, + email: p.email, + connected, + tokenExpiresAt: s.tokenExpiresAt, + jti: s.jti, + lastFolderPath: p.lastFolderPath || "", + }; +} + +/** + * 规范化逻辑分组路径(与 vault store normalize_folder_path 对齐)。 + * @param {string} [raw] + * @returns {string} + */ +export function normalizeFolderPath(raw) { + const s = String(raw || "").trim(); + if (!s) return ""; + const parts = s + .split(/[/\\]+/) + .map((p) => p.trim()) + .filter((p) => p && p !== "." && p !== ".."); + return parts.join("/"); +} + +/** + * 记住用户上次选用的分组。 + * @param {string} folderPath + */ +export async function saveLastFolderPath(folderPath) { + const fp = normalizeFolderPath(folderPath); + await savePersistent({ lastFolderPath: fp }); +} + +/** + * @param {boolean} connected + * @param {string} [label] + */ +export async function setBadge(connected, label) { + try { + if (connected) { + await chrome.action.setBadgeText({ text: "ON" }); + await chrome.action.setBadgeBackgroundColor({ color: "#0a7" }); + if (label) { + await chrome.action.setTitle({ title: `MNote Vault · ${label}` }); + } + } else { + await chrome.action.setBadgeText({ text: "—" }); + await chrome.action.setBadgeBackgroundColor({ color: "#888" }); + await chrome.action.setTitle({ title: "MNote Vault · 未连接" }); + } + } catch { + // ignore in non-extension env + } +} diff --git a/extensions/mnote-vault/manifest.json b/extensions/mnote-vault/manifest.json new file mode 100644 index 00000000..a8325ebf --- /dev/null +++ b/extensions/mnote-vault/manifest.json @@ -0,0 +1,49 @@ +{ + "manifest_version": 3, + "name": "MNote Vault", + "version": "0.2.3", + "description": "将外站登录/注册账号与浏览器登录态保存到 MNote 密码箱(提交缓存 → 登录后确认;每账号独立 session 文件)。", + "permissions": [ + "storage", + "activeTab", + "scripting", + "alarms", + "cookies", + "tabs" + ], + "optional_host_permissions": [ + "http://*/*", + "https://*/*" + ], + "host_permissions": [ + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_popup": "popup.html", + "default_title": "MNote Vault", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "options_page": "options.html", + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["content.js"], + "run_at": "document_idle", + "all_frames": false + } + ] +} diff --git a/extensions/mnote-vault/options.html b/extensions/mnote-vault/options.html new file mode 100644 index 00000000..3b9eff57 --- /dev/null +++ b/extensions/mnote-vault/options.html @@ -0,0 +1,145 @@ + + + + + MNote Vault · 连接设置 + + + +
+

MNote Vault

+

连接远程 MNote → 签发扩展 token → 外站保存账号与登录态

+ +
+ + + + + + + + + +
+ 高级:覆盖工作区 rootUri(一般不用) + + +

+ 每个账号登录后都有固定默认工作区( + …/users/<actor>/workspaces/my-space + )。留空即可;仅当你要把密码箱写到别的 local_folder 时才填 + file://…。 +

+
+ +
+ + + +
+
+

+
+ +
+ 使用说明 +
    +
  1. 确保 mnote-web 已启动;本机可用 http://127.0.0.1:3000,外网填 frp 完整地址(含端口)。
  2. +
  3. 填 baseUrl + 账号密码 →「连接并登录」。外网首次会弹出「允许访问该网站」,必须点允许。
  4. +
  5. rootUri 自动解析,不必手填。
  6. +
  7. 外站出现密码框后会弹出保存确认;默认勾选保存 Cookie。
  8. +
  9. 也可点工具栏图标手动「保存当前页」。
  10. +
+
+
+ + + diff --git a/extensions/mnote-vault/options.js b/extensions/mnote-vault/options.js new file mode 100644 index 00000000..585a2d7e --- /dev/null +++ b/extensions/mnote-vault/options.js @@ -0,0 +1,171 @@ +import { normalizeBaseUrl, vaultFetch } from "./lib/api.js"; +import { loadConfig, loadPersistent } from "./lib/storage.js"; + +const $ = (id) => document.getElementById(id); + +function setStatus(text, kind) { + const el = $("status"); + el.textContent = text || ""; + el.className = kind || ""; +} + +function setWorkspaceHint(rootUri, rootPath, source) { + const el = $("workspaceHint"); + if (!el) return; + if (!rootUri) { + el.textContent = ""; + return; + } + const path = rootPath || rootUri.replace(/^file:\/\//, ""); + const src = + source === "default" + ? "账号默认工作区" + : source === "override" + ? "手动覆盖" + : "已保存"; + el.innerHTML = `密码箱写入:${path}(${src})`; +} + +/** + * 必须在用户点击手势内调用:MV3 从 service worker 弹权限常被静默拒绝, + * 外网 baseUrl(含非标端口 frp)没有 host 权限时 fetch 就是 Failed to fetch。 + * 同时申请 http(s)://*/*,否则外站 Cookie 读不到、登录态无法保存。 + * @param {string} baseUrl + */ +async function requestBaseUrlPermission(baseUrl) { + const origin = new URL(normalizeBaseUrl(baseUrl)).origin; + // Broad first so cookie capture works on every login site after connect. + const broad = ["http://*/*", "https://*/*", `${origin}/*`]; + try { + const haveBroad = await chrome.permissions.contains({ origins: broad }); + if (haveBroad) return true; + const ok = await chrome.permissions.request({ origins: broad }); + if (ok) return true; + } catch { + /* fall through to narrow */ + } + const origins = [`${origin}/*`]; + const have = await chrome.permissions.contains({ origins }); + if (have) return true; + try { + return await chrome.permissions.request({ origins }); + } catch (e) { + throw new Error( + `无法申请访问权限 ${origin}:${e?.message || e}(请在扩展详情里允许该站点;登录态还需「所有网站」)` + ); + } +} + +function formatFetchError(e, baseUrl) { + const msg = e?.message || String(e); + if (/Failed to fetch|NetworkError|Load failed/i.test(msg)) { + return ( + `无法访问 ${baseUrl || "baseUrl"}:${msg}。` + + `常见原因:① 未点允许扩展访问该站点 ② 外网隧道未通 ③ 证书不受信任。` + + `请重新加载扩展后再点「测试连接」,并允许弹窗中的站点权限。` + ); + } + return msg; +} + +async function hydrate() { + const p = await loadPersistent(); + if (p.baseUrl) $("baseUrl").value = p.baseUrl; + // 默认不展示手填;仅当用户曾覆盖时回填并展开 + if (p.rootUri) { + $("rootUri").value = p.rootUri; + const adv = $("advRoot"); + if (adv && p.rootUri) { + // 不自动展开;连接后 hint 会显示实际路径 + } + } + if (p.email) $("email").value = p.email; + const cfg = await loadConfig(); + if (cfg.connected) { + setStatus( + `已连接 · ${cfg.email || cfg.userId || ""} · token 有效`, + "ok" + ); + setWorkspaceHint(cfg.rootUri || p.rootUri, p.rootPath, "saved"); + } else if (cfg.baseUrl) { + setStatus("已保存配置,但 token 缺失(浏览器重启后需重新登录)", "err"); + setWorkspaceHint(p.rootUri, p.rootPath, "saved"); + } +} + +$("test").onclick = async () => { + setStatus("测试中…"); + let baseUrl = ""; + try { + baseUrl = normalizeBaseUrl($("baseUrl").value); + const ok = await requestBaseUrlPermission(baseUrl); + if (!ok) { + setStatus( + `已取消访问权限:需要允许扩展访问 ${new URL(baseUrl).origin}`, + "err" + ); + return; + } + // whoami 不依赖 /api/health(外网入口可能未挂 health) + const data = await vaultFetch(baseUrl, "/api/auth/whoami", { + method: "GET", + credentials: true, + }); + setStatus( + `可达 · ${baseUrl} · whoami ok=${Boolean(data?.ok ?? true)}`, + "ok" + ); + } catch (e) { + setStatus(formatFetchError(e, baseUrl || $("baseUrl").value), "err"); + } +}; + +$("connect").onclick = async () => { + setStatus("登录中…"); + setWorkspaceHint("", ""); + const password = $("password").value; + let baseUrl = ""; + try { + baseUrl = normalizeBaseUrl($("baseUrl").value); + // 关键:在点击手势内申请 host 权限,再交给 background 登录 + const ok = await requestBaseUrlPermission(baseUrl); + if (!ok) { + setStatus( + `已取消访问权限:需要允许扩展访问 ${new URL(baseUrl).origin}(含端口)`, + "err" + ); + return; + } + const res = await chrome.runtime.sendMessage({ + type: "CONNECT", + baseUrl, + // 留空 → background 调 default workspace + rootUri: ($("rootUri").value || "").trim(), + email: $("email").value, + password, + }); + $("password").value = ""; + if (!res?.ok) { + setStatus(res?.error || "连接失败", "err"); + return; + } + const r = res.result || {}; + if (r.rootUri) $("rootUri").value = r.rootUri; + setStatus( + `已连接 · ${r.email || r.userId || ""} · 过期 ${r.expiresAt || "—"}`, + "ok" + ); + setWorkspaceHint(r.rootUri, r.rootPath, r.workspaceSource || "default"); + } catch (e) { + $("password").value = ""; + setStatus(formatFetchError(e, baseUrl || $("baseUrl").value), "err"); + } +}; + +$("disconnect").onclick = async () => { + await chrome.runtime.sendMessage({ type: "DISCONNECT" }); + setStatus("已断开", "err"); + setWorkspaceHint("", ""); +}; + +hydrate(); diff --git a/extensions/mnote-vault/popup.html b/extensions/mnote-vault/popup.html new file mode 100644 index 00000000..89b4193b --- /dev/null +++ b/extensions/mnote-vault/popup.html @@ -0,0 +1,45 @@ + + + + + MNote Vault + + + +

MNote Vault

+
检查中…
+ + + +
+ + + diff --git a/extensions/mnote-vault/popup.js b/extensions/mnote-vault/popup.js new file mode 100644 index 00000000..2cf4b8a7 --- /dev/null +++ b/extensions/mnote-vault/popup.js @@ -0,0 +1,80 @@ +const $ = (id) => document.getElementById(id); + +async function refresh() { + const res = await chrome.runtime.sendMessage({ type: "GET_STATUS" }); + const r = res?.result || {}; + const el = $("status"); + if (r.connected) { + el.className = "status on"; + el.textContent = `已连接 · ${r.email || r.userId || ""}`; + } else { + el.className = "status off"; + el.textContent = "未连接 — 请先在 Options 登录"; + } +} + +$("options").onclick = () => { + chrome.runtime.openOptionsPage(); +}; + +$("savePage").onclick = async () => { + $("msg").textContent = ""; + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) { + $("msg").textContent = "无活动标签"; + return; + } + try { + // Prefer pending draft (post-login) via background; falls back to live form. + const res = await chrome.runtime.sendMessage({ + type: "PROMPT_PENDING", + tabId: tab.id, + }); + if (!res?.ok) { + $("msg").textContent = res?.error || "无法打开确认层"; + return; + } + $("msg").textContent = "已在页面打开确认层"; + window.close(); + } catch (e) { + $("msg").textContent = + e?.message || + "无法注入当前页(受保护页面或未加载)。请在普通 http(s) 页重试。"; + } +}; + +$("updateSession").onclick = async () => { + $("msg").textContent = "匹配条目…"; + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.url) { + $("msg").textContent = "无活动标签 URL"; + return; + } + try { + const match = await chrome.runtime.sendMessage({ + type: "MATCH_URL", + pageUrl: tab.url, + }); + const items = match?.result?.items || []; + if (!items.length) { + $("msg").textContent = "无同站条目:请先保存账号"; + return; + } + const id = items[0].id; + const res = await chrome.runtime.sendMessage({ + type: "UPDATE_SESSION", + credentialId: id, + pageUrl: tab.url, + accountId: "primary", + }); + if (!res?.ok) { + $("msg").textContent = res?.error || "更新失败"; + return; + } + $("msg").textContent = `已更新登录态 · ${id}`; + } catch (e) { + $("msg").textContent = e?.message || String(e); + } +}; + +refresh(); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ae695a47..d136be21 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2424,6 +2424,8 @@ dependencies = [ "control-plane", "core-protocol", "futures-util", + "hex", + "hmac", "hyper 1.10.1", "hyper-util", "leptos", @@ -2434,6 +2436,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "time", "tokio", "tokio-stream", diff --git a/rust/crates/mnote-vault-core/src/ai.rs b/rust/crates/mnote-vault-core/src/ai.rs index 381483fd..3c3dc4e4 100644 --- a/rust/crates/mnote-vault-core/src/ai.rs +++ b/rust/crates/mnote-vault-core/src/ai.rs @@ -5,8 +5,8 @@ use crate::store::{ self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials, load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher, project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session, - resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord, - VaultItemStatus, VaultLoginPlaybook, VaultLoginSession, + resolve_login_session, resolve_record_secret_field, resolve_secret_with_cipher_book, + VaultCredentialRecord, VaultItemStatus, VaultLoginPlaybook, VaultLoginSession, }; use crate::token::DEFAULT_ACTOR; use serde_json::{json, Value}; @@ -376,6 +376,7 @@ pub fn put_ai_vault_session( expires_at: Some(expires.clone()), last_login_at: Some(now), source: Some(source.trim().to_string()), + ..Default::default() }; let record = put_login_session(&root, credential_id, session)?; let _ = append_vault_audit( @@ -432,8 +433,11 @@ pub fn login_ai_vault_credential( ) }); + // Prefer per-account session files (12-3); frontmatter is fallback inside resolve. + let resolved_session = resolve_login_session(&root, &record, None).ok().flatten(); + if playbook.is_human_required() && !force_refresh { - if let Some(sess) = record.login_session.as_ref() { + if let Some(sess) = resolved_session.as_ref() { if login_session_is_fresh(sess) { let cookie = sess.cookie_header.clone().unwrap_or_default(); let _ = append_vault_audit( @@ -452,6 +456,7 @@ pub fn login_ai_vault_credential( "mode": "session", "cookieHeader": cookie, "expiresAt": sess.expires_at, + "accountId": sess.account_id, "loginPlaybook": playbook, "transcriptHint": "已复用登录态", "note": "playbook=human_required;有可用 session 直接复用", @@ -479,7 +484,7 @@ pub fn login_ai_vault_credential( } if !force_refresh { - if let Some(sess) = record.login_session.as_ref() { + if let Some(sess) = resolved_session.as_ref() { if login_session_is_fresh(sess) { let cookie = sess.cookie_header.clone().unwrap_or_default(); let _ = append_vault_audit( @@ -499,6 +504,7 @@ pub fn login_ai_vault_credential( "cookieHeader": cookie, "expiresAt": sess.expires_at, "source": sess.source, + "accountId": sess.account_id, "loginPlaybook": { "mode": playbook.mode, "preferredAccount": playbook.preferred_account, @@ -736,6 +742,7 @@ pub fn login_ai_vault_credential( expires_at: Some(expires.clone()), last_login_at: Some(now), source: Some("api".into()), + ..Default::default() }; let _ = put_login_session(&root, credential_id, session)?; let _ = append_vault_audit( diff --git a/rust/crates/mnote-vault-core/src/store.rs b/rust/crates/mnote-vault-core/src/store.rs index 26b285d1..4351f96d 100644 --- a/rust/crates/mnote-vault-core/src/store.rs +++ b/rust/crates/mnote-vault-core/src/store.rs @@ -168,8 +168,32 @@ pub struct VaultLoginPlaybook { pub human_note: Option, } +/// Structured cookie from chrome.cookies / browser capture (secret values). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct VaultSessionCookie { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secure: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http_only: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub same_site: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expiration_date: Option, +} + /// Captured browser/API session for multi-agent reuse (cookie header is secret). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +/// +/// **Disk truth (12-3):** `sessions/{credId}/{accountId}.json`. +/// Frontmatter `loginSession` is read-only fallback for pre-12-3 data. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "camelCase")] pub struct VaultLoginSession { /// Full Cookie request header value (secret). @@ -179,11 +203,44 @@ pub struct VaultLoginSession { pub expires_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_login_at: Option, - /// api | browser | human_bridge + /// chrome_extension | api | browser | human_bridge | login_api #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cookies: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, } +/// On-disk session file schema (`mnote.vault.session.v1`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VaultSessionFile { + pub schema: String, + pub credential_id: String, + pub account_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cookie_header: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cookies: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: 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, + pub source: String, + pub updated_at: String, + pub revision: u64, +} + +pub const VAULT_SESSION_SCHEMA: &str = "mnote.vault.session.v1"; +pub const SESSION_ACCOUNT_PRIMARY: &str = "primary"; + 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() { @@ -411,9 +468,11 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result Result) -> String { + let raw = account_id.map(str::trim).unwrap_or(""); + if raw.is_empty() || raw.eq_ignore_ascii_case(SESSION_ACCOUNT_PRIMARY) { + SESSION_ACCOUNT_PRIMARY.to_string() + } else { + // reject path traversal + if raw.contains('/') || raw.contains('\\') || raw.contains("..") { + return SESSION_ACCOUNT_PRIMARY.to_string(); + } + raw.to_string() + } +} + +/// `sessions/{credId}/` under vault root. +pub fn session_dir_rel(credential_id: &str) -> String { + format!("sessions/{credential_id}") +} + +/// `sessions/{credId}/{accountId}.json` +pub fn session_file_rel(credential_id: &str, account_id: Option<&str>) -> String { + let acc = normalize_session_account_id(account_id); + format!("sessions/{credential_id}/{acc}.json") +} + +fn session_dir_abs(vault: &Path, credential_id: &str) -> PathBuf { + vault.join("sessions").join(credential_id) +} + +fn session_file_abs(vault: &Path, credential_id: &str, account_id: Option<&str>) -> PathBuf { + let acc = normalize_session_account_id(account_id); + session_dir_abs(vault, credential_id).join(format!("{acc}.json")) +} + +/// Build Cookie header from structured cookies (preferred when both present). +pub fn cookie_header_from_cookies(cookies: &[VaultSessionCookie]) -> String { + cookies + .iter() + .filter_map(|c| { + let name = c.name.trim(); + if name.is_empty() { + return None; + } + let value = c.value.as_deref().unwrap_or("").trim(); + Some(format!("{name}={value}")) + }) + .collect::>() + .join("; ") +} + +fn session_file_to_login(file: &VaultSessionFile) -> VaultLoginSession { + VaultLoginSession { + cookie_header: file.cookie_header.clone(), + expires_at: file.expires_at.clone(), + last_login_at: file.last_login_at.clone(), + source: Some(file.source.clone()), + origin: file.origin.clone(), + account_id: Some(file.account_id.clone()), + cookies: file.cookies.clone(), + revision: Some(file.revision), + } +} + +fn read_session_file_at(path: &Path) -> Result, WebError> { + if !path.exists() { + return Ok(None); + } + let raw = fs::read_to_string(path).map_err(|error| { + WebError::bad_request_code( + "vault_session_read_failed", + format!("无法读取登录态文件: {error}"), + ) + })?; + let file: VaultSessionFile = serde_json::from_str(&raw).map_err(|error| { + WebError::bad_request_code( + "vault_session_parse_failed", + format!("登录态文件 JSON 无效: {error}"), + ) + })?; + Ok(Some(file)) +} + +/// Read one session file (no frontmatter fallback). +pub fn read_session_file( + workspace_root: &Path, + credential_id: &str, + account_id: Option<&str>, +) -> Result, WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let path = session_file_abs(&vault, credential_id, account_id); + Ok(read_session_file_at(&path)?.map(|f| session_file_to_login(&f))) +} + +/// List accountIds that have a session file for this credential. +pub fn list_session_account_ids( + workspace_root: &Path, + credential_id: &str, +) -> Result, WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let dir = session_dir_abs(&vault, credential_id); + if !dir.exists() { + return Ok(Vec::new()); + } + let mut ids = Vec::new(); + let entries = fs::read_dir(&dir).map_err(|error| { + WebError::bad_request_code( + "vault_session_list_failed", + format!("无法列出登录态目录: {error}"), + ) + })?; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(stem) = name.strip_suffix(".json") { + if !stem.is_empty() { + ids.push(stem.to_string()); + } + } + } + ids.sort(); + Ok(ids) +} + +/// Resolve session for login: explicit account → primary → accounts[0] → frontmatter. +pub fn resolve_login_session( + workspace_root: &Path, + record: &VaultCredentialRecord, + account_id: Option<&str>, +) -> Result, WebError> { + let vault = ensure_vault_directories(workspace_root)?; + if let Some(aid) = account_id.map(str::trim).filter(|s| !s.is_empty()) { + if let Some(sess) = read_session_file_at(&session_file_abs(&vault, &record.id, Some(aid)))? + .map(|f| session_file_to_login(&f)) + { + return Ok(Some(sess)); + } + // explicit account miss: do not fall through to other accounts + return Ok(record + .login_session + .clone() + .filter(|s| s.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false))); + } + + // primary file + if let Some(sess) = read_session_file_at(&session_file_abs( + &vault, + &record.id, + Some(SESSION_ACCOUNT_PRIMARY), + ))? + .map(|f| session_file_to_login(&f)) + { + return Ok(Some(sess)); + } + + // first multi-account slot + if let Some(first) = record.accounts.first() { + let aid = first.id.trim(); + if !aid.is_empty() { + if let Some(sess) = + read_session_file_at(&session_file_abs(&vault, &record.id, Some(aid)))? + .map(|f| session_file_to_login(&f)) + { + return Ok(Some(sess)); + } + } + } + + // any other session file + for aid in list_session_account_ids(workspace_root, &record.id)? { + if aid == SESSION_ACCOUNT_PRIMARY { + continue; + } + if let Some(sess) = + read_session_file_at(&session_file_abs(&vault, &record.id, Some(&aid)))? + .map(|f| session_file_to_login(&f)) + { + return Ok(Some(sess)); + } + } + + // frontmatter fallback (legacy) + Ok(record.login_session.clone().filter(|s| { + s.cookie_header + .as_ref() + .map(|c| !c.trim().is_empty()) + .unwrap_or(false) + })) +} + +/// Whether credential has any usable session (files or frontmatter). +pub fn credential_has_login_session( + workspace_root: &Path, + record: &VaultCredentialRecord, +) -> bool { + if let Ok(ids) = list_session_account_ids(workspace_root, &record.id) { + if !ids.is_empty() { + return true; + } + } + record + .login_session + .as_ref() + .and_then(|s| s.cookie_header.as_ref()) + .map(|c| !c.trim().is_empty()) + .unwrap_or(false) +} + +fn best_session_expires_at( + workspace_root: &Path, + record: &VaultCredentialRecord, +) -> Option { + if let Ok(Some(sess)) = resolve_login_session(workspace_root, record, None) { + return sess.expires_at; + } + record + .login_session + .as_ref() + .and_then(|s| s.expires_at.clone()) +} + +fn remove_sessions_dir(vault: &Path, credential_id: &str) { + let dir = session_dir_abs(vault, credential_id); + if dir.exists() { + let _ = fs::remove_dir_all(&dir); + } +} + +fn move_sessions_dir(vault: &Path, credential_id: &str, to_trash: bool) { + let active = session_dir_abs(vault, credential_id); + let trash = vault.join("trash").join("sessions").join(credential_id); + if to_trash { + if active.exists() { + let _ = fs::create_dir_all(trash.parent().unwrap_or(vault)); + let _ = move_path(&active, &trash); + } + } else if trash.exists() { + let _ = fs::create_dir_all(active.parent().unwrap_or(vault)); + let _ = move_path(&trash, &active); + } +} + +/// Copy `sessions/{src_id}/**` → `sessions/{dst_id}/**` (share-to-ai). +pub fn copy_session_files( + source_workspace: &Path, + target_workspace: &Path, + source_id: &str, + target_id: &str, +) -> Result { + let src_vault = ensure_vault_directories(source_workspace)?; + let dst_vault = ensure_vault_directories(target_workspace)?; + let src_dir = session_dir_abs(&src_vault, source_id); + if !src_dir.exists() { + // migrate frontmatter-only session into target primary.json if present + return Ok(0); + } + let dst_dir = session_dir_abs(&dst_vault, target_id); + fs::create_dir_all(&dst_dir).map_err(|error| { + WebError::bad_request_code( + "vault_session_copy_failed", + format!("无法创建目标登录态目录: {error}"), + ) + })?; + let mut count = 0usize; + let entries = fs::read_dir(&src_dir).map_err(|error| { + WebError::bad_request_code( + "vault_session_copy_failed", + format!("无法读取源登录态目录: {error}"), + ) + })?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let name = entry.file_name(); + let dst_path = dst_dir.join(&name); + // rewrite credentialId inside file + if let Ok(Some(mut file)) = read_session_file_at(&path) { + file.credential_id = target_id.to_string(); + file.updated_at = now_rfc3339(); + let json = serde_json::to_string_pretty(&file).map_err(|e| { + WebError::bad_request_code( + "vault_session_copy_failed", + format!("序列化登录态失败: {e}"), + ) + })?; + atomic_write_string(&dst_path, &json)?; + count += 1; + } else { + let _ = fs::copy(&path, &dst_path); + count += 1; + } + } + Ok(count) +} + +fn atomic_write_string(path: &Path, content: &str) -> Result<(), WebError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::bad_request_code( + "vault_session_write_failed", + format!("无法创建目录: {error}"), + ) + })?; + } + let tmp = path.with_extension("json.tmp"); + { + let mut f = fs::File::create(&tmp).map_err(|error| { + WebError::bad_request_code( + "vault_session_write_failed", + format!("无法创建临时文件: {error}"), + ) + })?; + f.write_all(content.as_bytes()).map_err(|error| { + WebError::bad_request_code( + "vault_session_write_failed", + format!("无法写入登录态: {error}"), + ) + })?; + f.sync_all().ok(); + } + fs::rename(&tmp, path).map_err(|error| { + WebError::bad_request_code( + "vault_session_write_failed", + format!("无法落盘登录态: {error}"), + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +fn patch_index_session_meta( + workspace_root: &Path, + credential_id: &str, + has_session: bool, + expires_at: Option, +) -> Result<(), WebError> { + let vault = ensure_vault_directories(workspace_root)?; + let mut index = load_index(&vault)?; + if let Some(entry) = index.entries.get_mut(credential_id) { + entry.has_login_session = has_session; + entry.session_expires_at = expires_at; + index.revision = index.revision.saturating_add(1); + index.updated_at = now_rfc3339(); + write_index_atomic(&vault, &index)?; + } + Ok(()) +} + pub fn now_rfc3339() -> String { OffsetDateTime::now_utc() .format(&Rfc3339) @@ -1165,6 +1577,8 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry { revision: record.revision, shared_to_ai: record.shared_to_ai.clone(), shared_from: record.shared_from.clone(), + // Index is written without workspace path for file scan; use frontmatter + // flag here. List/get projections re-check session files when workspace known. has_login_session: record .login_session .as_ref() @@ -1183,6 +1597,23 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry { } } +/// Refresh has_login_session / session_expires_at from disk session files. +pub fn enrich_index_entry_session( + workspace_root: &Path, + entry: &mut VaultIndexEntry, +) { + if let Ok(ids) = list_session_account_ids(workspace_root, &entry.id) { + if !ids.is_empty() { + entry.has_login_session = true; + if let Ok(record) = get_credential(workspace_root, &entry.id) { + entry.session_expires_at = best_session_expires_at(workspace_root, &record); + } + return; + } + } + // keep frontmatter-derived flags already on entry +} + /// 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 { @@ -2178,6 +2609,7 @@ fn parse_login_session(map: &BTreeMap) -> Option Result<(), WebError> if trash_att.exists() { let _ = fs::remove_dir_all(&trash_att); } + // purge active + trash session dirs + remove_sessions_dir(&vault, id); + let trash_sess = vault.join("trash").join("sessions").join(id); + if trash_sess.exists() { + let _ = fs::remove_dir_all(&trash_sess); + } index.entries.remove(id); index.revision = index.revision.saturating_add(1); index.updated_at = now_rfc3339(); @@ -3444,21 +3911,41 @@ pub fn project_item_l0_with_cipher( "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()), + "hasLoginSession": false, + "sessionExpiresAt": Value::Null, + "lastLoginAt": Value::Null, }); + // Prefer session files when workspace known; else frontmatter. + let resolved_sess = workspace_root + .and_then(|root| resolve_login_session(root, record, None).ok().flatten()) + .or_else(|| record.login_session.clone()); + if let Some(sess) = resolved_sess.as_ref() { + let has = sess + .cookie_header + .as_ref() + .map(|c| !c.trim().is_empty()) + .unwrap_or(false); + item["hasLoginSession"] = json!(has); + item["sessionExpiresAt"] = json!(sess.expires_at); + item["lastLoginAt"] = json!(sess.last_login_at); + // Never project cookieHeader / cookies[].value in L0. + item["loginSession"] = json!({ + "hasCookie": has, + "expiresAt": sess.expires_at, + "lastLoginAt": sess.last_login_at, + "source": sess.source, + "accountId": sess.account_id, + "origin": sess.origin, + }); + } + if let Some(root) = workspace_root { + if let Ok(ids) = list_session_account_ids(root, &record.id) { + if !ids.is_empty() { + item["sessionAccountIds"] = json!(ids); + item["hasLoginSession"] = json!(true); + } + } + } if let Some(t) = username_template { item["usernameTemplate"] = json!(t); } @@ -3483,15 +3970,6 @@ pub fn project_item_l0_with_cipher( "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 } @@ -3559,24 +4037,116 @@ pub fn project_list_entry(entry: &VaultIndexEntry) -> Value { project_list_entry_with_cipher(entry, None) } -/// Persist login session on a credential (AI vault session write-back). +/// Persist login session as `sessions/{id}/{accountId}.json` (12-3). +/// Does **not** write cookie into credential frontmatter (legacy field left as-is or cleared). pub fn put_login_session( workspace_root: &Path, id: &str, session: VaultLoginSession, ) -> Result { - let mut record = get_credential(workspace_root, id)?; + let account_id = session.account_id.clone(); + put_login_session_for_account(workspace_root, id, account_id.as_deref(), session) +} + +/// Write session file for a specific account slot (`primary` when omitted). +pub fn put_login_session_for_account( + workspace_root: &Path, + id: &str, + account_id: Option<&str>, + mut session: VaultLoginSession, +) -> Result { + let 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) + + // Prefer structured cookies for header when both present. + if !session.cookies.is_empty() { + let rebuilt = cookie_header_from_cookies(&session.cookies); + if !rebuilt.is_empty() { + session.cookie_header = Some(rebuilt); + } + } + let cookie = session + .cookie_header + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code( + "vault_session_cookie_required", + "cookieHeader 与 cookies[] 至少一个非空", + ) + })?; + + let acc = normalize_session_account_id( + account_id.or(session.account_id.as_deref()), + ); + let vault = ensure_vault_directories(workspace_root)?; + let path = session_file_abs(&vault, id, Some(&acc)); + + let prev_rev = read_session_file_at(&path)? + .map(|f| f.revision) + .unwrap_or(0); + let now = now_rfc3339(); + let expires = session + .expires_at + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| { + let hours = std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS") + .ok() + .and_then(|s| s.trim().parse().ok()) + .filter(|h: &i64| *h > 0) + .unwrap_or(168); + (OffsetDateTime::now_utc() + time::Duration::hours(hours)) + .format(&Rfc3339) + .unwrap_or_else(|_| now.clone()) + }); + let source = session + .source + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("api") + .to_string(); + let last_login = session + .last_login_at + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| now.clone()); + + let file = VaultSessionFile { + schema: VAULT_SESSION_SCHEMA.into(), + credential_id: id.to_string(), + account_id: acc.clone(), + cookie_header: Some(cookie.to_string()), + cookies: session.cookies.clone(), + origin: session.origin.clone(), + expires_at: Some(expires.clone()), + last_login_at: Some(last_login), + source, + updated_at: now, + revision: prev_rev.saturating_add(1), + }; + let json = serde_json::to_string_pretty(&file).map_err(|e| { + WebError::bad_request_code( + "vault_session_write_failed", + format!("序列化登录态失败: {e}"), + ) + })?; + atomic_write_string(&path, &json)?; + + // Index meta only — do not bump credential revision / rewrite md for cookie. + patch_index_session_meta(workspace_root, id, true, Some(expires))?; + + // Return record with resolved session for callers that inspect login_session. + let mut out = record; + out.login_session = Some(session_file_to_login(&file)); + Ok(out) } pub fn put_login_playbook( @@ -4409,4 +4979,159 @@ mod tests { assert_eq!(created.accounts[0].password.as_deref(), Some("secret")); let _ = fs::remove_dir_all(&root); } + + #[test] + fn session_file_put_resolve_and_share_copy() { + let src = temp_root(); + let dst = temp_root(); + let created = create_credential( + &src, + VaultCreateInput { + title: "session-file-test".into(), + url: Some("https://example.com/login".into()), + username: Some("user@ex.com".into()), + password: Some("secret".into()), + ..Default::default() + }, + ) + .unwrap(); + + let put = put_login_session( + &src, + &created.id, + VaultLoginSession { + // When cookies[] present, header is rebuilt from them (preferred). + cookie_header: Some("ignored-if-cookies-present".into()), + expires_at: Some("2099-01-01T00:00:00Z".into()), + last_login_at: Some("2026-07-24T12:00:00Z".into()), + source: Some("chrome_extension".into()), + origin: Some("https://example.com".into()), + account_id: None, + cookies: vec![ + VaultSessionCookie { + name: "sid".into(), + value: Some("abc".into()), + domain: Some(".example.com".into()), + path: Some("/".into()), + secure: Some(true), + http_only: Some(true), + same_site: Some("lax".into()), + expiration_date: None, + }, + VaultSessionCookie { + name: "csrf".into(), + value: Some("xyz".into()), + domain: Some(".example.com".into()), + path: Some("/".into()), + secure: Some(true), + http_only: Some(false), + same_site: Some("lax".into()), + expiration_date: None, + }, + ], + revision: None, + }, + ) + .unwrap(); + assert_eq!( + put.login_session + .as_ref() + .and_then(|s| s.cookie_header.as_deref()), + Some("sid=abc; csrf=xyz") + ); + + let path = vault_root(&src) + .join("sessions") + .join(&created.id) + .join("primary.json"); + assert!(path.exists(), "session file should exist at {:?}", path); + let raw = fs::read_to_string(&path).unwrap(); + assert!(raw.contains("mnote.vault.session.v1")); + assert!(raw.contains("chrome_extension")); + // credential md must not contain cookie plaintext after file write + let md = fs::read_to_string(vault_root(&src).join(&created.relative_path)).unwrap(); + assert!(!md.contains("sid=abc")); + + let resolved = resolve_login_session(&src, &created, None).unwrap().unwrap(); + assert_eq!( + resolved.cookie_header.as_deref(), + Some("sid=abc; csrf=xyz") + ); + assert!(login_session_is_fresh(&resolved)); + + let (_, list) = list_credentials(&src, VaultItemStatus::Active).unwrap(); + let entry = list.iter().find(|e| e.id == created.id).unwrap(); + assert!(entry.has_login_session); + + let l0 = project_item_l0_with_cipher(&created, Some(&src)); + assert_eq!(l0["hasLoginSession"], true); + assert!(!l0.to_string().contains("sid=abc")); + + let _ = put_login_session_for_account( + &src, + &created.id, + Some("acc_alt"), + VaultLoginSession { + cookie_header: Some("sid=alt".into()), + expires_at: Some("2099-06-01T00:00:00Z".into()), + source: Some("api".into()), + ..Default::default() + }, + ) + .unwrap(); + let ids = list_session_account_ids(&src, &created.id).unwrap(); + assert!(ids.contains(&"primary".to_string())); + assert!(ids.contains(&"acc_alt".to_string())); + + let shared = share_credential_to_workspace( + &src, + &dst, + &created.id, + Some("from-user"), + &[], + false, + "user-a", + "ai-agent", + ) + .unwrap(); + let dst_ids = list_session_account_ids(&dst, &shared.item.id).unwrap(); + assert!( + dst_ids.contains(&"primary".to_string()), + "share must copy session files" + ); + let dst_sess = resolve_login_session(&dst, &shared.item, None) + .unwrap() + .unwrap(); + assert!(dst_sess + .cookie_header + .as_ref() + .map(|c| c.contains("sid=abc")) + .unwrap_or(false)); + + soft_delete_credential(&src, &created.id).unwrap(); + assert!(!vault_root(&src) + .join("sessions") + .join(&created.id) + .exists()); + assert!(vault_root(&src) + .join("trash") + .join("sessions") + .join(&created.id) + .exists()); + restore_credential(&src, &created.id).unwrap(); + assert!(vault_root(&src) + .join("sessions") + .join(&created.id) + .exists()); + soft_delete_credential(&src, &created.id).unwrap(); + purge_credential(&src, &created.id).unwrap(); + assert!(!vault_root(&src) + .join("trash") + .join("sessions") + .join(&created.id) + .exists()); + + let _ = fs::remove_dir_all(&src); + let _ = fs::remove_dir_all(&dst); + } } diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index 0afab5ae..e705c6f1 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -30,7 +30,10 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] } tower = "0.5" base64 = "0.22" comrak = { version = "0.52", default-features = false } +hex = "0.4" +hmac = "0.12" notify = "8.2.0" -time = { version = "0.3", features = ["formatting", "local-offset"] } +sha2 = "0.10" +time = { version = "0.3", features = ["formatting", "local-offset", "parsing"] } uuid = { version = "1", features = ["v4"] } zip = "2" diff --git a/rust/crates/mnote-web/browser/agent-stream-event-router.js b/rust/crates/mnote-web/browser/agent-stream-event-router.js deleted file mode 100644 index 5356c55c..00000000 --- a/rust/crates/mnote-web/browser/agent-stream-event-router.js +++ /dev/null @@ -1,127 +0,0 @@ -// AgentStreamEventRouter — 统一的 agent 流式事件状态机 -// 供 Page AI sidebar runtime 和 document editor adapter runtime 共用 -// -// 事件状态: -// init — 建立 request_id 绑定 -// loading — message_delta / tool_call_delta → 增量更新 -// stream_event — tool-started / tool-finished / agent_state -// finished — 正常终止 -// interrupted — 中断(等待审批/恢复) -// error — 错误终止 -// approval_required — 等待用户确认 - -export const AGENT_EVENT_STATES = { - INIT: 'init', - LOADING: 'loading', - STREAM_EVENT: 'stream_event', - FINISHED: 'finished', - INTERRUPTED: 'interrupted', - ERROR: 'error', - APPROVAL_REQUIRED: 'approval_required', -}; - -export const TERMINAL_STATES = new Set([ - AGENT_EVENT_STATES.FINISHED, - AGENT_EVENT_STATES.INTERRUPTED, - AGENT_EVENT_STATES.ERROR, -]); - -export function isTerminalState(status) { - return TERMINAL_STATES.has(status); -} - -// 解析 SSE 帧为 eventName + payloadText -export function parseSSEFrames(buffer, lastBoundary) { - var frames = buffer.split('\n\n'); - var remaining = frames.pop() || ''; - var events = []; - frames.forEach(function(frame) { - var eventName = ''; - var dataLines = []; - frame.split('\n').forEach(function(line) { - if (line.startsWith('event:')) eventName = line.slice(6).trim(); - if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); - }); - var payloadText = dataLines.join('\n'); - if (!eventName && payloadText) { - try { - var parsed = JSON.parse(payloadText); - eventName = parsed && parsed.event ? String(parsed.event) : ''; - } catch (_) {} - } - if (eventName) events.push({ eventName: eventName, payloadText: payloadText }); - }); - return { events: events, remaining: remaining }; -} - -// 从 SSE 流读取事件 -export async function readSSEStream(response, onFrame) { - if (!response.body || typeof response.body.getReader !== 'function') return; - var reader = response.body.getReader(); - var decoder = new TextDecoder(); - var buffer = ''; - while (true) { - var chunk = await reader.read(); - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - var result = parseSSEFrames(buffer, ''); - buffer = result.remaining; - result.events.forEach(function(evt) { - onFrame(evt.eventName, evt.payloadText); - }); - } -} - -// 创建标准事件路由器 -// handlers: { onInit, onDelta, onToolCall, onToolResult, onAgentState, onTerminal, onApprovalRequired } -export function createAgentStreamRouter(handlers) { - var h = handlers || {}; - - return function routeEvent(eventName, payloadText) { - var payload = null; - try { payload = JSON.parse(payloadText || 'null'); } catch (_) {} - - var status = (payload && payload.status) || eventName; - - switch (status) { - case AGENT_EVENT_STATES.INIT: - if (h.onInit) h.onInit(payload); - break; - - case AGENT_EVENT_STATES.LOADING: - if (h.onDelta) h.onDelta(payload); - if (h.onToolCall) { - var toolChunks = (payload && payload.tool_call_chunks) || (payload && payload.msg && payload.msg.tool_call_chunks); - if (toolChunks && toolChunks.length) h.onToolCall(payload); - } - break; - - case AGENT_EVENT_STATES.STREAM_EVENT: - if (payload && payload.event === 'tool-finished') { - if (h.onToolResult) h.onToolResult(payload); - } else if (payload && payload.event === 'tool-started') { - // tool start — 可选处理 - } else if (payload && payload.agent_state) { - if (h.onAgentState) h.onAgentState(payload.agent_state); - } - break; - - case AGENT_EVENT_STATES.FINISHED: - case AGENT_EVENT_STATES.INTERRUPTED: - case AGENT_EVENT_STATES.ERROR: - if (h.onTerminal) h.onTerminal(status, payload); - break; - - case AGENT_EVENT_STATES.APPROVAL_REQUIRED: - if (h.onApprovalRequired) h.onApprovalRequired(payload); - break; - - default: - // 未知状态:尝试作为 loading 处理 - if (payload && (payload.text || payload.delta || payload.content)) { - if (h.onDelta) h.onDelta(payload); - } - break; - } - }; -} diff --git a/rust/crates/mnote-web/browser/document-session-runtime.js b/rust/crates/mnote-web/browser/document-session-runtime.js index 8f88d468..98f17745 100644 --- a/rust/crates/mnote-web/browser/document-session-runtime.js +++ b/rust/crates/mnote-web/browser/document-session-runtime.js @@ -621,7 +621,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { const conflictSourceLabel = (session) => { if (!session) return ''; - if (session.lastExternalWriteSource === 'mnote-hermes-tool') { + const source = String(session.lastExternalWriteSource || ''); + // 兼容历史 externalActor 名;产品面统一称 agent tool + if (source === 'mnote-agent-tool' || source === 'mnote-hermes-tool') { const runId = String(session.lastExternalWriteRunId || '').trim(); return runId ? `agent run ${runId}` : 'agent run'; } @@ -1820,7 +1822,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => { window.addEventListener('tree:delta', handleTreeExternalChange); window.addEventListener('tree:resync', handleTreeExternalChange); window.addEventListener('mnote:page-ai-tool-write-completed', (event) => { - refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool'); + refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-agent-tool'); }); window.addEventListener('mnote:local-upload-editor-save-completed', (event) => { applyLocalUploadEditorSave(event?.detail || {}); diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js deleted file mode 100644 index 17e95aad..00000000 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js +++ /dev/null @@ -1,359 +0,0 @@ -export function createSidebarPageAiMarkdownRuntime(context) { - const { escapeHtml } = context; - - function textFromUnknown(value) { - if (value == null) return ''; - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' '); - if (typeof value !== 'object') return ''; - var parts = []; - ['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - var text = textFromUnknown(value[key]); - if (text) parts.push(text); - } - }); - return parts.join(' '); - } - - function renderPageAiMarkdownInline(text) { - var html = escapeHtml(String(text || '')); - var codeSpans = []; - var htmlSpans = []; - function stashHtml(value) { - var key = '\u0000HTML' + htmlSpans.length + '\u0000'; - htmlSpans.push(value); - return key; - } - html = html.replace(/`([^`\n]+)`/g, function(_, code) { - var key = '\u0000CODE' + codeSpans.length + '\u0000'; - codeSpans.push('' + code + ''); - return key; - }); - html = html.replace(/\[((?:\\.|[^\]\n])+)\]\(([^)\n]+)\)/g, function(match, label, href) { - var normalizedHref = normalizePageAiMarkdownHref(href); - if (!normalizedHref) return match; - var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : ''; - return stashHtml('' + unescapePageAiMarkdownLabel(label) + ''); - }); - html = html.replace(/(^|[\s(])((?:https?:\/\/|\/documents\/|mnote:\/\/open(?:Resource)?)[^\s<>()]+[^\s<>().,;:!?])/g, function(_, prefix, href) { - var normalizedHref = normalizePageAiMarkdownHref(href); - if (!normalizedHref) return prefix + href; - var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : ''; - return prefix + stashHtml('' + escapeHtml(normalizedHref) + ''); - }); - html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1'); - html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); - codeSpans.forEach(function(value, index) { - html = html.replace('\u0000CODE' + index + '\u0000', value); - }); - htmlSpans.forEach(function(value, index) { - html = html.replace('\u0000HTML' + index + '\u0000', value); - }); - return html; - } - - function normalizePageAiMarkdownHref(value) { - var href = String(value || '').trim() - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'"); - while (href.includes('&')) href = href.replace(/&/g, '&'); - if (href.startsWith('<') && href.endsWith('>')) href = href.slice(1, -1).trim(); - if (!href || /[\u0000-\u001f<>"']/.test(href)) return ''; - var lower = href.toLowerCase(); - if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) return ''; - if (lower.startsWith('mnote://open')) href = normalizePageAiMnoteOpenHref(href); - href = normalizePageAiLegacyCitationHref(href); - if (href.startsWith('/') || href.startsWith('#')) return href; - if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) return href; - return ''; - } - - function unescapePageAiMarkdownLabel(value) { - return String(value || '').replace(/\\([\\\[\]()*_`])/g, '$1'); - } - - function normalizePageAiMnoteOpenHref(href) { - try { - var url = new URL(href); - if (url.protocol !== 'mnote:' || (url.hostname !== 'open' && url.hostname !== 'openResource')) return href; - var path = String(url.searchParams.get('path') || url.searchParams.get('resourcePath') || '').trim(); - if (!path) return ''; - var params = new URLSearchParams(); - var rootUri = String(url.searchParams.get('rootUri') || '').trim(); - if (!rootUri) { - try { - rootUri = new URL(window.location.href).searchParams.get('rootUri') || ''; - } catch (_locationError) {} - } - if (rootUri) params.set('rootUri', rootUri); - params.set('path', path); - ['page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) { - var value = String(url.searchParams.get(key) || '').trim(); - if (value) params.set(key, value); - }); - return '/api/local-folder/files/open?' + params.toString(); - } catch (_error) { - return href; - } - } - - function normalizePageAiLegacyCitationHref(href) { - try { - var url = new URL(href, window.location.origin); - var unwrappedHref = normalizePageAiSearchWrappedCitationHref(url); - if (unwrappedHref) return normalizePageAiLegacyCitationHref(unwrappedHref); - if (!isPageAiSameMnoteOrigin(url) && !isPageAiPortableMnoteCitationUrl(url)) return href; - if (url.pathname === '/api/local-folder/files/open') return url.pathname + url.search; - if (!url.pathname.startsWith('/documents/')) return href; - if (isPageAiUnsafeCitationDocumentId(url)) { - var fileOpenHref = normalizePageAiCitationFileOpenHref(url); - if (fileOpenHref) return fileOpenHref; - } - var decodedHash = ''; - try { - decodedHash = decodeURIComponent(url.hash || ''); - } catch (_decodeError) { - decodedHash = url.hash || ''; - } - var marker = '#resource-tab-'; - var markerIndex = decodedHash.indexOf(marker); - if (markerIndex < 0 || url.searchParams.get('resourceTab')) { - return url.pathname + url.search + url.hash; - } - var identity = decodedHash.slice(markerIndex + marker.length).replace(/^#+/, '').trim(); - if (!identity.startsWith('resource:file:')) return url.pathname + url.search + url.hash; - url.searchParams.set('resourceTab', identity); - if (!url.searchParams.get('sourceKind')) url.searchParams.set('sourceKind', 'local_folder'); - var rootUri = String(url.searchParams.get('rootUri') || '').trim(); - var prefix = 'resource:file:' + rootUri + ':'; - if (rootUri && identity.startsWith(prefix) && !url.searchParams.get('resourcePath')) { - url.searchParams.set('resourcePath', identity.slice(prefix.length).replace(/^\/+/, '')); - } - url.hash = ''; - return url.pathname + url.search; - } catch (_error) { - return href; - } - } - - function normalizePageAiSearchWrappedCitationHref(url) { - var raw = ''; - ['wd', 'q', 'query'].some(function(key) { - raw = String(url.searchParams.get(key) || '').trim(); - return Boolean(raw); - }); - if (!raw) return ''; - if (/^documents\//i.test(raw)) raw = '/' + raw; - if (!/^\/documents\//i.test(raw) && !/^https?:\/\/[^/]+\/documents\//i.test(raw) && !/^mnote:\/\/open/i.test(raw)) return ''; - if (/^mnote:\/\/open/i.test(raw)) return normalizePageAiMnoteOpenHref(raw); - var nested = new URL(raw, window.location.origin); - if (!nested.pathname.startsWith('/documents/')) return ''; - ['sourceKind', 'rootUri', 'workspaceId', 'resourceTab', 'resourcePath', 'page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) { - if (nested.searchParams.get(key)) return; - var value = String(url.searchParams.get(key) || '').trim(); - if (value) nested.searchParams.set(key, value); - }); - return nested.pathname + nested.search; - } - - function normalizePageAiCitationFileOpenHref(url) { - var rootUri = String(url.searchParams.get('rootUri') || '').trim(); - var path = String(url.searchParams.get('resourcePath') || '').trim(); - if (!rootUri || !path) return ''; - var params = new URLSearchParams(); - params.set('rootUri', rootUri); - params.set('path', path); - ['page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) { - var value = String(url.searchParams.get(key) || '').trim(); - if (value) params.set(key, value); - }); - return '/api/local-folder/files/open?' + params.toString(); - } - - function isPageAiUnsafeCitationDocumentId(url) { - try { - var raw = String(url.pathname || '').replace(/^\/documents\//, '').split('/')[0] || ''; - if (!raw) return false; - var decoded = decodeURIComponent(raw); - return decoded.indexOf('%') >= 0 || decoded.indexOf('\uFFFD') >= 0; - } catch (_error) { - return true; - } - } - - function isPageAiPortableMnoteCitationUrl(url) { - try { - if (url.pathname === '/api/local-folder/files/open') { - return Boolean(String(url.searchParams.get('rootUri') || '').trim()) && - Boolean(String(url.searchParams.get('path') || '').trim()); - } - if (!url.pathname.startsWith('/documents/')) return false; - var resourceTab = String(url.searchParams.get('resourceTab') || '').trim(); - return String(url.searchParams.get('sourceKind') || '').trim() === 'local_folder' || - Boolean(String(url.searchParams.get('rootUri') || '').trim()) || - Boolean(String(url.searchParams.get('resourcePath') || '').trim()) || - resourceTab.startsWith('resource:file:'); - } catch (_error) { - return false; - } - } - - function isPageAiMarkdownTableDivider(line) { - var cells = splitPageAiMarkdownTableRow(line); - if (cells.length < 2) return false; - return cells.every(function(cell) { - return /^:?-{3,}:?$/.test(cell.trim()); - }); - } - - function splitPageAiMarkdownTableRow(line) { - var text = String(line || '').trim(); - if (!text.includes('|')) return []; - if (text.startsWith('|')) text = text.slice(1); - if (text.endsWith('|')) text = text.slice(0, -1); - return text.split('|').map(function(cell) { return cell.trim(); }); - } - - function renderPageAiMarkdownTable(lines, startIndex) { - if (startIndex + 1 >= lines.length || !isPageAiMarkdownTableDivider(lines[startIndex + 1])) return null; - var header = splitPageAiMarkdownTableRow(lines[startIndex]); - var divider = splitPageAiMarkdownTableRow(lines[startIndex + 1]); - if (!header.length || header.length !== divider.length) return null; - var rows = []; - var index = startIndex + 2; - while (index < lines.length && lines[index].trim() && lines[index].includes('|')) { - var cells = splitPageAiMarkdownTableRow(lines[index]); - if (!cells.length) break; - rows.push(cells); - index += 1; - } - function cellHtml(tag, value) { - return '<' + tag + '>' + renderPageAiMarkdownInline(value) + ''; - } - var head = '' + header.map(function(cell) { return cellHtml('th', cell); }).join('') + ''; - var body = rows.length - ? '' + rows.map(function(row) { - return '' + header.map(function(_, cellIndex) { return cellHtml('td', row[cellIndex] || ''); }).join('') + ''; - }).join('') + '' - : ''; - return { - html: '
' + head + body + '
', - nextIndex: index - }; - } - - function isMnoteCitationHref(href) { - try { - var url = new URL(href, window.location.origin); - if (!isPageAiSameMnoteOrigin(url)) return false; - return url.pathname.startsWith('/documents/') || url.pathname === '/api/local-folder/files/open'; - } catch (_error) { - return false; - } - } - - function isPageAiSameMnoteOrigin(url) { - try { - var current = new URL(window.location.origin); - if (url.origin === current.origin) return true; - if (url.hostname === 'mnote.local') return true; - var localNames = ['localhost', '127.0.0.1', '::1', 'mnote.local']; - return localNames.indexOf(url.hostname) >= 0 && - localNames.indexOf(current.hostname) >= 0 && - String(url.port || defaultPortForProtocol(url.protocol)) === String(current.port || defaultPortForProtocol(current.protocol)); - } catch (_error) { - return false; - } - } - - function defaultPortForProtocol(protocol) { - return protocol === 'https:' ? '443' : protocol === 'http:' ? '80' : ''; - } - - function renderPageAiMarkdown(content) { - var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n'); - var blocks = []; - var index = 0; - function isBlockBoundary(line) { - return !line.trim() || - /^```/.test(line.trim()) || - /^#{1,6}\s+/.test(line) || - /^\s*[-*]\s+/.test(line) || - /^\s*\d+[.)]\s+/.test(line); - } - while (index < lines.length) { - var line = lines[index]; - if (!line.trim()) { - index += 1; - continue; - } - if (/^```/.test(line.trim())) { - index += 1; - var codeLines = []; - while (index < lines.length && !/^```/.test(lines[index].trim())) { - codeLines.push(lines[index]); - index += 1; - } - if (index < lines.length) index += 1; - blocks.push('
' + escapeHtml(codeLines.join('\n')) + '
'); - continue; - } - if (/^\s*-{3,}\s*$/.test(line)) { - blocks.push('
'); - index += 1; - continue; - } - var table = renderPageAiMarkdownTable(lines, index); - if (table) { - blocks.push(table.html); - index = table.nextIndex; - continue; - } - var heading = line.match(/^(#{1,6})\s+(.+)$/); - if (heading) { - var level = Math.min(6, heading[1].length); - blocks.push('' + renderPageAiMarkdownInline(heading[2]) + ''); - index += 1; - continue; - } - if (/^\s*[-*]\s+/.test(line)) { - var unordered = []; - while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) { - unordered.push('
  • ' + renderPageAiMarkdownInline(lines[index].replace(/^\s*[-*]\s+/, '')) + '
  • '); - index += 1; - } - blocks.push('
      ' + unordered.join('') + '
    '); - continue; - } - if (/^\s*\d+[.)]\s+/.test(line)) { - var ordered = []; - while (index < lines.length && /^\s*\d+[.)]\s+/.test(lines[index])) { - ordered.push('
  • ' + renderPageAiMarkdownInline(lines[index].replace(/^\s*\d+[.)]\s+/, '')) + '
  • '); - index += 1; - } - blocks.push('
      ' + ordered.join('') + '
    '); - continue; - } - var paragraph = []; - while (index < lines.length && !isBlockBoundary(lines[index])) { - paragraph.push(renderPageAiMarkdownInline(lines[index])); - index += 1; - } - if (paragraph.length) { - blocks.push('

    ' + paragraph.join('
    ') + '

    '); - } else { - index += 1; - } - } - return blocks.join('') || escapeHtml(String(content || '')); - } - - return { - textFromUnknown, - renderPageAiMarkdown, - renderPageAiMarkdownInline - }; -} diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-permission-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-permission-runtime.js deleted file mode 100644 index 694c475a..00000000 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-permission-runtime.js +++ /dev/null @@ -1,344 +0,0 @@ -export function createSidebarPageAiPermissionRuntime(context) { - const { - documentRef, - pageAiPreviewValue, - pageUiState, - renderPageAiConversation, - } = context; - const doc = documentRef || document; - - function pageAiPermissionStorageKey() { - var sessionId = String(pageUiState.pageAiActiveSessionId || 'no-session').trim() || 'no-session'; - var path = doc && doc.location ? String(doc.location.pathname || '') : ''; - return 'mnote.page_ai.permission_queue.v1:' + path + ':' + sessionId; - } - - function pageAiPersistPermissionRequests() { - try { - var pending = pageUiState.pageAiPermissionRequests.filter(function(item) { - return item && item.kind === 'permission' && !item.resolved; - }).slice(-20); - if (window.localStorage) window.localStorage.setItem(pageAiPermissionStorageKey(), JSON.stringify(pending)); - } catch (_) {} - } - - function pageAiRestorePermissionRequests() { - try { - var raw = window.localStorage ? window.localStorage.getItem(pageAiPermissionStorageKey()) : ''; - var items = raw ? JSON.parse(raw) : []; - if (!Array.isArray(items)) items = []; - pageUiState.pageAiPermissionRequests = items.filter(function(item) { - return item && item.kind === 'permission' && !item.resolved; - }).slice(-20); - pageUiState.pageAiPermissionRequests.forEach(function(item) { - var exists = pageUiState.pageAiMessages.some(function(message) { - return message.kind === 'permission' && message.permissionId === item.permissionId; - }); - if (!exists) pageUiState.pageAiMessages.push(item); - }); - var pending = pageUiState.pageAiPermissionRequests.find(function(item) { return !item.resolved; }); - if (pending) { - if (!pageAiApplyPermissionMode(pending)) pageAiShowPermissionDialog(pending); - } - return pageUiState.pageAiPermissionRequests; - } catch (_) { - return []; - } - } - - function pageAiPermissionMessage(payload, eventType) { - payload = payload && typeof payload === 'object' ? payload : {}; - var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim(); - var detail = pageAiPermissionDetail(payload, permissionId); - var toolName = detail.toolName || String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim(); - var args = payload.arguments || payload.args || payload.input || payload.params || payload; - var decision = String(payload.decision || payload.result || '').trim(); - if (!decision && eventType === 'permission.denied') decision = 'denied'; - if (!decision && eventType === 'permission.allowed') decision = 'allowed'; - var options = pageAiPermissionOptions(payload); - return { - role: 'tool', - kind: 'permission', - permissionId: permissionId, - runId: String(payload.runId || payload.run_id || pageUiState.pageAiCurrentRunId || '').trim(), - toolName: toolName, - argsSummary: detail.summary || pageAiPreviewValue(args), - content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'), - resolved: decision === 'denied' || decision === 'allowed', - decision: decision, - options: options, - permissionDetails: detail - }; - } - - function pageAiPermissionDetail(payload, permissionId) { - var params = payload && payload.params && typeof payload.params === 'object' ? payload.params : {}; - var toolCall = params.toolCall && typeof params.toolCall === 'object' ? params.toolCall : {}; - var rawInput = toolCall.rawInput && typeof toolCall.rawInput === 'object' ? toolCall.rawInput : {}; - var title = String(toolCall.title || payload.toolName || payload.tool || payload.name || '').trim(); - var kind = String(toolCall.kind || params.kind || '').trim(); - var command = String(rawInput.command || rawInput.cmd || '').trim(); - var path = String(rawInput.path || rawInput.file || rawInput.target || '').trim(); - var toolName = title || String(payload.toolName || payload.tool || payload.name || 'session/request_permission').trim(); - var primary = command || path || String(rawInput.pattern || rawInput.query || '').trim() || title || '权限请求'; - var rows = []; - rows.push({ label: '操作', value: toolName }); - if (kind) rows.push({ label: '类型', value: pageAiPermissionKindLabel(kind) }); - if (command) rows.push({ label: '命令', value: command }); - return { - toolName: toolName, - kind: kind, - kindLabel: pageAiPermissionKindLabel(kind), - command: command, - path: path, - summary: primary, - rows: rows - }; - } - - function pageAiPermissionKindLabel(kind) { - var normalized = String(kind || '').trim().toLowerCase(); - if (normalized === 'execute') return '执行命令'; - if (normalized === 'edit') return '写入文件'; - if (normalized === 'read') return '读取'; - if (normalized === 'other') return '其他'; - return kind || ''; - } - - function pageAiPermissionOptions(payload) { - var rawOptions = payload && Array.isArray(payload.options) - ? payload.options - : (payload && payload.params && Array.isArray(payload.params.options) ? payload.params.options : []); - return rawOptions.map(function(option) { - option = option && typeof option === 'object' ? option : {}; - var optionId = String(option.optionId || option.option_id || option.id || '').trim(); - if (!optionId) return null; - return { - optionId: optionId, - name: pageAiPermissionOptionLabel(optionId, option), - kind: String(option.kind || '').trim() - }; - }).filter(Boolean).slice(0, 8); - } - - function pageAiPermissionOptionLabel(optionId, option) { - var id = String(optionId || '').trim().toLowerCase(); - var raw = String((option && (option.name || option.title)) || '').trim(); - if (id === 'allow_once') return raw && !/^allow$/i.test(raw) ? raw.replace(/^Allow\b/i, '允许') : '允许一次'; - if (id === 'allow_always') return '本会话允许'; - if (id === 'allow_persistent') return '始终允许'; - if (id === 'reject_once' || id === 'reject') return '拒绝'; - if (id === 'cancel') return '取消'; - if (id === 'refine' || id === 'revise') return '要求修改'; - if (id === 'accept') return '接受'; - return raw || optionId || '选择'; - } - - function pageAiApplyPermissionEvent(eventName, payloadText) { - var payload = null; - try { - payload = JSON.parse(payloadText || 'null'); - } catch (_) { - payload = {}; - } - var message = pageAiPermissionMessage(payload, eventName); - var existing = pageUiState.pageAiMessages.find(function(item) { - return item.kind === 'permission' && item.permissionId === message.permissionId; - }); - if (existing) { - Object.assign(existing, message); - } else { - pageUiState.pageAiMessages.push(message); - } - pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) { - return item.permissionId !== message.permissionId; - }).concat([message]).slice(-20); - pageAiPersistPermissionRequests(); - if (!message.resolved && pageAiApplyPermissionMode(message)) { - return; - } - if (!message.resolved) { - pageAiShowPermissionDialog(message); - } else { - pageAiHidePermissionDialog(); - } - } - - function pageAiReasonixApprovalMode() { - var preferences = pageUiState.pageAiSkillPreferences && typeof pageUiState.pageAiSkillPreferences === 'object' - ? pageUiState.pageAiSkillPreferences - : {}; - var mode = String(preferences['ai.agent.reasonix.approval_mode'] || 'ask').trim() || 'ask'; - return mode === 'allow' || mode === 'deny' ? mode : 'ask'; - } - - function pageAiApplyPermissionMode(message) { - if (!message || message.resolved) return false; - var mode = pageAiReasonixApprovalMode(); - if (mode === 'ask') return false; - var optionId = pageAiPermissionPreferredOptionId(message, mode); - window.setTimeout(function() { - pageAiResolvePermission(message.permissionId, mode, optionId); - }, 0); - return true; - } - - function pageAiPermissionPreferredOptionId(message, decision) { - var options = Array.isArray(message && message.options) ? message.options : []; - if (!options.length) return ''; - var allowKeywords = ['allow_once', 'allow', 'approve', 'yes', 'allow_always', 'allow_persistent']; - var denyKeywords = ['deny_once', 'reject_once', 'deny', 'reject', 'no', 'cancel', 'stop']; - var keywords = decision === 'allow' ? allowKeywords : denyKeywords; - for (var i = 0; i < keywords.length; i += 1) { - var keyword = keywords[i]; - var found = options.find(function(option) { - var text = [option && option.optionId, option && option.kind, option && option.name].map(function(value) { - return String(value || '').toLowerCase(); - }).join(' '); - return text.indexOf(keyword) >= 0; - }); - if (found && found.optionId) return String(found.optionId || ''); - } - if (decision === 'allow') { - var allowFallback = options.find(function(option) { return !pageAiPermissionOptionRejectLike(option); }); - return allowFallback && allowFallback.optionId ? String(allowFallback.optionId || '') : ''; - } - return ''; - } - - function pageAiResolvePermission(permissionId, decision, optionId) { - permissionId = String(permissionId || '').trim(); - decision = String(decision || '').trim() || 'deny'; - optionId = String(optionId || '').trim(); - if (!permissionId) return; - var runId = String(pageUiState.pageAiCurrentRunId || '').trim(); - if (!runId && pageUiState.pageAiActiveSessionId && Array.isArray(pageUiState.pageAiSessions)) { - var activeSession = pageUiState.pageAiSessions.find(function(session) { - return session && session.id === pageUiState.pageAiActiveSessionId; - }); - runId = String(activeSession && (activeSession.runId || activeSession.hostRunId) || '').trim(); - } - if (!runId && Array.isArray(pageUiState.pageAiPermissionRequests)) { - var pending = pageUiState.pageAiPermissionRequests.find(function(item) { - return item && item.permissionId === permissionId; - }); - runId = String(pending && pending.runId || '').trim(); - } - if (!runId && doc && doc.documentElement) { - runId = String(doc.documentElement.getAttribute('data-mnote-page-ai-run-id') || doc.documentElement.getAttribute('data-mnote-page-ai-active-host-run-id') || '').trim(); - } - if (runId) { - var body = { permissionId: permissionId, decision: decision }; - if (optionId) body.optionId = optionId; - fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body) - }).then(function(response) { - if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status); - }).catch(function(err) { - console.warn('resolve-permission 请求失败', err); - }); - } else { - console.warn('resolve-permission: 无活跃 runId,只能本地更新'); - } - pageUiState.pageAiMessages.forEach(function(item) { - if (item.kind === 'permission' && item.permissionId === permissionId) { - item.resolved = true; - item.decision = decision; - item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求'; - } - }); - var dialog = doc.querySelector('[data-page-ai-permission-dialog]'); - if (dialog instanceof HTMLElement) dialog.hidden = true; - pageAiPersistPermissionRequests(); - renderPageAiConversation(); - } - - function pageAiHidePermissionDialog() { - var dialog = doc.querySelector('[data-page-ai-permission-dialog]'); - if (dialog instanceof HTMLElement) dialog.hidden = true; - } - - function pageAiShowPermissionDialog(message) { - if (!message || message.kind !== 'permission') return; - if (message.resolved) { - pageAiHidePermissionDialog(); - return; - } - var dialog = doc.querySelector('[data-page-ai-permission-dialog]'); - if (!(dialog instanceof HTMLElement)) { - dialog = doc.createElement('div'); - dialog.className = 'wolai-page-ai-permission-dialog'; - dialog.setAttribute('data-page-ai-permission-dialog', 'true'); - dialog.innerHTML = '' + - ''; - doc.body.appendChild(dialog); - } - var tool = dialog.querySelector('[data-page-ai-permission-tool]'); - if (tool instanceof HTMLElement) tool.textContent = "权限 - " + (message.toolName || "session/request_permission"); - var args = dialog.querySelector('[data-page-ai-permission-args]'); - if (args instanceof HTMLElement) args.innerHTML = pageAiPermissionDetailsHtml(message); - var actions = dialog.querySelector('[data-page-ai-permission-actions]'); - if (actions instanceof HTMLElement) actions.innerHTML = pageAiPermissionActionsHtml(message); - dialog.hidden = false; - } - - function pageAiPermissionDetailsHtml(message) { - var details = message && message.permissionDetails && typeof message.permissionDetails === 'object' - ? message.permissionDetails - : {}; - var rows = Array.isArray(details.rows) ? details.rows : []; - if (!rows.length) { - return '
    请求' + escapeHtml(message.argsSummary || message.content || '') + '
    '; - } - return rows.slice(0, 6).map(function(row) { - return '
    ' + escapeHtml(row.label || '') + '' + escapeHtml(row.value || '') + '
    '; - }).join(''); - } - - function pageAiPermissionActionsHtml(message) { - var options = Array.isArray(message.options) ? message.options : []; - if (!options.length) { - return '' + - '' + - ''; - } - return options.map(function(option) { - var denyLike = pageAiPermissionOptionRejectLike(option); - return ''; - }).join(''); - } - - function pageAiPermissionOptionRejectLike(option) { - var text = String((option && option.optionId) || '') + ' ' + String((option && option.kind) || '') + ' ' + String((option && option.name) || ''); - text = text.toLowerCase(); - return /reject|deny|cancel|stop|no/.test(text); - } - - function escapeAttr(value) { - return escapeHtml(String(value || '')); - } - - function escapeHtml(value) { - return String(value == null ? '' : value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - return { - pageAiPermissionMessage, - pageAiApplyPermissionEvent, - pageAiResolvePermission, - pageAiRestorePermissionRequests, - pageAiHidePermissionDialog, - pageAiShowPermissionDialog - }; -} diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js deleted file mode 100644 index 8bd88f2f..00000000 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js +++ /dev/null @@ -1,281 +0,0 @@ -export function createSidebarPageAiProfileRuntime(context) { - const { - chatOnlyProfileRegistry, - documentRef, - pageAiAgentRecord, - pageAiCurrentAgentId, - pageAiNormalizeAgentId, - pageUiState, - } = context; - const PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY = Array.isArray(chatOnlyProfileRegistry) ? chatOnlyProfileRegistry : []; - - function pageAiProviderLabel(provider) { - if (provider === 'codex') return 'Codex'; - if (provider === 'claudecode') return 'ClaudeCode'; - return 'Hermes'; - } - - function pageAiNormalizeArray(value) { - return Array.isArray(value) ? value : []; - } - - function pageAiDefaultAcpRuntimes() { - return [ - { - name: 'reasonix', - title: 'ACP · Reasonix', - description: '通过 ACP 协议直连 Reasonix(DeepSeek 缓存优先)', - model: 'deepseek-chat', - preset: 'auto' - }, - { - name: 'hermes', - title: 'ACP · Hermes', - description: '通过 ACP 协议直连 Hermes agent runtime' - } - ]; - } - - function pageAiNormalizeAcpRuntimes(runtimes) { - var byName = {}; - pageAiDefaultAcpRuntimes().forEach(function(runtime) { - byName[runtime.name] = Object.assign({}, runtime); - }); - pageAiNormalizeArray(runtimes).forEach(function(runtime) { - var name = String(runtime && runtime.name || '').trim(); - if (name !== 'reasonix' && name !== 'hermes') return; - byName[name] = Object.assign({}, byName[name] || {}, runtime, { name: name }); - }); - return ['reasonix', 'hermes'].map(function(name) { return byName[name]; }).filter(Boolean); - } - - function pageAiUnwrapUpstream(payload) { - if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream; - return payload || null; - } - - function pageAiProfileValue(profile) { - if (profile && typeof profile === 'object') { - return String(profile.profileId || profile.name || profile.profile || profile.id || '').trim(); - } - return String(profile || '').trim(); - } - - function pageAiCurrentProfile() { - var active = String(pageUiState.pageAiActiveProfileName || '').trim(); - if (active) return active; - var selected = pageUiState.pageAiProfiles.find(function(profile) { - return profile && profile.active; - }); - return pageAiProfileValue(selected) || 'mnoteai'; - } - - function pageAiRunProfile() { - if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return 'reasonix'; - if (pageAiCurrentAgentId() === 'chat_only') return pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile()); - return pageAiCurrentProfile(); - } - - function pageAiMnoteToolModel() { - var doc = documentRef || document; - return String(doc.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash'; - } - - function pageAiCurrentProfileRecord() { - var active = pageAiCurrentProfile(); - return pageUiState.pageAiProfiles.find(function(profile) { - return pageAiProfileValue(profile) === active; - }) || null; - } - - function pageAiChatOnlyProfileSpec(profile) { - var profileId = pageAiProfileValue(profile); - var baseProfile = String(profile && profile.baseProfile || '').trim(); - var isolatedProfile = String(profile && profile.isolatedProfile || '').trim(); - return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY.find(function(spec) { - return spec.profileId === profileId || spec.baseProfile === profileId || spec.baseProfile === baseProfile || isolatedProfile.indexOf(spec.baseProfile) >= 0; - }) || null; - } - - function pageAiDefaultChatOnlyProfileSpec() { - return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY[0] || { profileId: 'shared_deepseek_chat', baseProfile: 'deepseek-chat', label: 'DeepSeek' }; - } - - function pageAiNormalizeChatOnlyProfileId(profileId) { - var profile = pageAiProfileRecordById(profileId) || { profileId: String(profileId || '').trim(), name: String(profileId || '').trim() }; - var spec = pageAiChatOnlyProfileSpec(profile); - return (spec || pageAiDefaultChatOnlyProfileSpec()).profileId; - } - - function pageAiProfileDisplayLabel(profile, fallback) { - var alias = String(profile && profile.alias || '').trim(); - var displayName = String(profile && (profile.displayName || profile.label || '') || '').trim(); - var name = pageAiProfileValue(profile); - return alias || displayName || fallback || name || 'default'; - } - - function pageAiProfileRecordById(profileId) { - var normalized = String(profileId || '').trim(); - if (!normalized) return null; - return pageAiNormalizeArray(pageUiState.pageAiProfiles).find(function(profile) { - return pageAiProfileValue(profile) === normalized; - }) || null; - } - - function pageAiSessionAgentFilterValue(session) { - if (String(session && session.source || '') === 'board' || session && session.workerPresetId) return 'board:mnote-page-ai'; - var agentId = pageAiNormalizeAgentId(session && session.agentId); - if (agentId === 'reasonix') return 'reasonix'; - var profileId = String(session && (session.profileId || session.profile) || '').trim(); - if (agentId === 'chat_only') profileId = pageAiNormalizeChatOnlyProfileId(profileId); - return agentId + ':' + profileId; - } - - function pageAiSessionAgentLabel(session) { - if (String(session && session.source || '') === 'board' || session && session.workerPresetId) { - var worker = String(session && session.workerPresetId || 'mnote-page-ai-zcode').trim(); - var model = String(session && session.modelOverride || '').trim(); - return 'Agent Board / ' + worker + (model ? ' / ' + model : ''); - } - var agentId = pageAiNormalizeAgentId(session && session.agentId); - if (agentId === 'reasonix') return pageAiAgentRecord(agentId).label || 'Reasonix'; - var profileId = String(session && (session.profileId || session.profile) || '').trim(); - var profile = pageAiProfileRecordById(profileId) || { profileId: profileId, name: profileId }; - var chatOnlySpec = pageAiChatOnlyProfileSpec(profile); - if (agentId === 'chat_only') { - return 'ChatOnly / ' + (chatOnlySpec || pageAiDefaultChatOnlyProfileSpec()).label; - } - if (agentId === 'hermes') return 'Hermes / ' + pageAiProfileDisplayLabel(profile, profileId); - return pageAiAgentRecord(agentId).label; - } - - function pageAiSessionPreviewText(session) { - var preview = Array.isArray(session && session.messages) && session.messages.length - ? String(session.messages.slice(-1)[0].content || '') - : String(session && (session.snippet || session.preview || '暂无消息') || '暂无消息'); - preview = preview.replace(/\s+/g, ' ').trim(); - var limit = 96; - return preview.length > limit ? preview.slice(0, limit) + '…' : preview; - } - - function pageAiSessionAgentFilterOptions(rows) { - var byValue = { all: '全部 agent' }; - pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) { - var value = pageAiSessionAgentFilterValue(session); - byValue[value] = pageAiSessionAgentLabel(session); - }); - return Object.keys(byValue).map(function(value) { - return { value: value, label: byValue[value] }; - }); - } - - function pageAiSessionStatusFilterValue(session) { - var status = String(session && session.status || '').trim(); - var mode = String(session && (session.runtimeMode || session.mode) || '').trim(); - if (pageUiState.pageAiActiveSessionId && session && session.id === pageUiState.pageAiActiveSessionId) return 'active'; - if (session && session.replaySeen) return 'replay_seen'; - if (mode === 'native_live' || mode === 'cold_resumed' || mode === 'replay_seen') return mode; - if (['running', 'tool_calling', 'queued', 'pending', 'acp_pending'].indexOf(status) >= 0) return 'active'; - if (['failed', 'aborted', 'cancelled', 'canceled'].indexOf(status) >= 0) return 'failed'; - if (status === 'completed') return 'completed'; - return status || 'unknown'; - } - - function pageAiSessionStatusLabel(value) { - return { - all: '全部状态', - active: 'Active', - completed: 'Completed', - failed: 'Failed', - native_live: 'Reasonix native-live', - cold_resumed: 'Cold resumed', - replay_seen: 'Replay seen', - unknown: 'Unknown' - }[value] || value; - } - - function pageAiSessionStatusFilterOptions(rows) { - var byValue = { - all: pageAiSessionStatusLabel('all'), - active: pageAiSessionStatusLabel('active'), - completed: pageAiSessionStatusLabel('completed'), - failed: pageAiSessionStatusLabel('failed'), - native_live: pageAiSessionStatusLabel('native_live'), - cold_resumed: pageAiSessionStatusLabel('cold_resumed'), - replay_seen: pageAiSessionStatusLabel('replay_seen') - }; - pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) { - var value = pageAiSessionStatusFilterValue(session); - byValue[value] = pageAiSessionStatusLabel(value); - }); - return Object.keys(byValue).map(function(value) { - return { value: value, label: byValue[value] }; - }); - } - - function pageAiVisibleHistorySession(session) { - var source = String(session && session.source || '').trim(); - var status = String(session && session.status || '').trim(); - var messages = pageAiNormalizeArray(session && session.messages); - return !(source === 'draft' && status === 'draft' && messages.length === 0); - } - - function pageAiFilteredHistoryRows(rows) { - var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all'; - var statusFilter = String(pageUiState.pageAiSessionStatusFilter || 'all').trim() || 'all'; - var normalized = pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession); - return normalized.filter(function(session) { - return (filterValue === 'all' || pageAiSessionAgentFilterValue(session) === filterValue) - && (statusFilter === 'all' || pageAiSessionStatusFilterValue(session) === statusFilter); - }); - } - - function pageAiTimestamp(value) { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim()) { - var parsed = Date.parse(value); - if (Number.isFinite(parsed)) return parsed; - } - return Date.now(); - } - - function pageAiUsageSummary(usage) { - if (!usage || typeof usage !== 'object') return ''; - var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens; - var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens; - var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens; - var parts = []; - if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used)); - if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size)); - if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output)); - return parts.join(' ') || ''; - } - - return { - pageAiProviderLabel, - pageAiNormalizeArray, - pageAiDefaultAcpRuntimes, - pageAiNormalizeAcpRuntimes, - pageAiUnwrapUpstream, - pageAiProfileValue, - pageAiCurrentProfile, - pageAiRunProfile, - pageAiMnoteToolModel, - pageAiCurrentProfileRecord, - pageAiChatOnlyProfileSpec, - pageAiDefaultChatOnlyProfileSpec, - pageAiNormalizeChatOnlyProfileId, - pageAiProfileDisplayLabel, - pageAiProfileRecordById, - pageAiSessionAgentFilterValue, - pageAiSessionAgentLabel, - pageAiSessionPreviewText, - pageAiSessionAgentFilterOptions, - pageAiSessionStatusFilterValue, - pageAiSessionStatusLabel, - pageAiSessionStatusFilterOptions, - pageAiFilteredHistoryRows, - pageAiTimestamp, - pageAiUsageSummary - }; -} diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js deleted file mode 100644 index 6e39ae38..00000000 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js +++ /dev/null @@ -1,1878 +0,0 @@ -export function createSidebarPageAiRenderRuntime(context) { - const { - contextRefRegistry, - cssEscape, - currentDocumentId, - documentRef, - escapeHtml, - localMarkdownRelativePathFromPageAiDocumentId, - pageAiAgentDescriptor, - pageAiAgentRecord, - pageAiAllSkillEntries, - pageAiBuildAllowedRoots, - pageAiChatOnlyProfileEntries, - pageAiChatOnlyProfileSpec, - pageAiCurrentAgentId, - pageAiCurrentProfile, - pageAiCurrentProfileRecord, - pageAiCurrentSession, - pageAiCurrentSkillSource, - pageAiCurrentSkillSourceLabel, - pageAiDefaultChatOnlyProfileSpec, - pageAiDescriptorFieldOptions, - pageAiDescriptorFieldValue, - pageAiEditorTargetCandidates, - pageAiEnsureContextRefState, - pageAiFilteredHistoryRows, - pageAiHideHermesBuiltinSkills, - pageAiHermesProfileEntries, - pageAiHermesSettingsUrl, - pageAiMnoteToolModel, - pageAiNormalizeAcpRuntimes, - pageAiNormalizeArray, - pageAiNormalizeSkillSource, - pageAiProfileDisplayLabel, - pageAiProfileValue, - pageAiProviderLabel, - pageAiReasonixMemoryEnabled, - pageAiRunProfile, - pageAiSessionAgentFilterOptions, - pageAiSessionAgentLabel, - pageAiSessionAgentFilterValue, - pageAiSessionStatusFilterOptions, - pageAiSessionStatusFilterValue, - pageAiSessionStatusLabel, - pageAiSessionPreviewText, - pageAiSessionStorageLabel, - pageAiSessionSelected, - pageAiSelectedSessionCount, - pageAiSkillEnabled, - pageAiSkillGroupCollapsed, - pageAiSkillListEntries, - pageAiSkillOriginLabel, - pageAiSkillSourceOptions, - pageAiSkillSourceParts, - pageAiToggleableSkillEntries, - pageAiUsageSummary, - pageAiBoardFirstEnabled, - pageAiBoardWorkerOptions, - pageAiBoardWorkflowOptions, - pageAiBoardSelectedWorkerId, - pageAiBoardSelectedWorkflowId, - pageAiBoardWorkerLabel, - pageAiBoardWorkflowLabel, - pageAiBoardModelOptions, - pageAiBoardSelectedModelOverride, - pageAiBoardModelLabel, - pageUiState, - renderPageAiMarkdown, - searchText, - currentPageAiEditorTarget, - currentPageAiSelectedText, - } = context; - const PAGE_AI_CONTEXT_REF_REGISTRY = Array.isArray(contextRefRegistry) ? contextRefRegistry : []; - - function pageAiLocalBoardFirstEnabled() { - try { - return window.localStorage ? window.localStorage.getItem('mnote.page_ai.legacy_provider_mode') !== '1' : true; - } catch (_error) { - return true; - } - } - - function isPageAiBoardFirstEnabled() { - return typeof pageAiBoardFirstEnabled === 'function' ? pageAiBoardFirstEnabled() : pageAiLocalBoardFirstEnabled(); - } - - function pageAiTargetLabel(target) { - var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {}; - var kind = String(target && (target.resourceKind || target.editorKind) || workspacePath.resourceKind || '').trim(); - var title = String(target && target.title || '').trim(); - var relativePath = String(workspacePath.relativePath || target && target.path || '').trim(); - if (title) return title; - if (relativePath) return relativePath.split('/').filter(Boolean).pop() || relativePath; - if (kind === 'mindmap') return '思维导图'; - if (kind === 'only_office') return 'Office 资源'; - return '当前页'; - } - - function pageAiTargetDetail(target) { - var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {}; - var kind = String(target && (target.resourceKind || target.editorKind) || workspacePath.resourceKind || '').trim(); - var pane = String(target && target.paneRole || '').trim(); - var relativePath = String(workspacePath.relativePath || target && target.path || '').trim(); - return [kind || 'page', pane, relativePath].filter(Boolean).join(' · '); - } - - function pageAiContextRefLabel(kind) { - var record = PAGE_AI_CONTEXT_REF_REGISTRY.find(function(ref) { return ref.id === kind; }); - return record ? record.label : kind; - } - - function pageAiContextButtonSummary() { - var selected = pageAiEnsureContextRefState(); - var labels = PAGE_AI_CONTEXT_REF_REGISTRY - .filter(function(ref) { return selected[ref.id] === true; }) - .map(function(ref) { return ref.label; }); - if (!labels.length) return '选择上下文'; - var head = labels.slice(0, 2).join(' + '); - return labels.length > 2 ? head + ' +' + String(labels.length - 2) : head; - } - - function pageAiContextRefDetail(kind) { - var documentId = currentDocumentId(); - var relativePath = localMarkdownRelativePathFromPageAiDocumentId(documentId); - if (kind === 'current_page') return relativePath || documentId || '当前页面'; - if (kind === 'selection') return currentPageAiSelectedText() ? '当前选区可用' : '当前没有选区'; - if (kind === 'active_editor') { - var target = currentPageAiEditorTarget(); - return String(target && (target.title || target.documentId) || relativePath || '当前打开资源'); - } - if (kind === 'file') return relativePath || '当前文件'; - if (kind === 'folder') { - var roots = pageAiBuildAllowedRoots(); - if (!roots.length) return '需要授权后可用'; - return roots.map(function(root) { return root.rootUri.replace(/^file:\/\//, ''); }).filter(Boolean)[0] || '已授权文件夹'; - } - if (kind === 'changed_files') return '本次 run 后可用于追问'; - return ''; - } - - function pageAiContextRefDisabled(kind) { - if (kind === 'selection') return !currentPageAiSelectedText(); - return false; - } - - function pageAiSuggestions() { - var title = searchText(documentRef.querySelector('[data-page-title-current="true"]')?.textContent) || '当前页面'; - return [ - '帮我总结《' + title + '》当前内容', - '把当前页面改写得更简洁一些', - '提炼当前页的关键待办和行动项', - '基于当前页内容生成一个三段式摘要' - ]; - } - - function pageAiCurrentAgentSelectionLabel() { - var agentId = pageAiCurrentAgentId(); - var descriptor = pageAiAgentDescriptor(agentId); - var descriptorLabel = String(descriptor && (descriptor.displayName || descriptor.label) || '').trim(); - if (agentId === 'reasonix') return descriptorLabel || 'Reasonix'; - var profile = pageAiCurrentProfileRecord(); - var chatOnlySpec = pageAiChatOnlyProfileSpec(profile || pageAiCurrentProfile()); - if (agentId === 'chat_only') { - return 'ChatOnly / ' + (chatOnlySpec || pageAiDefaultChatOnlyProfileSpec()).label; - } - if (chatOnlySpec) { - return 'ChatOnly / ' + chatOnlySpec.label; - } - if (agentId === 'hermes') return (descriptorLabel || 'Hermes') + ' / ' + pageAiProfileDisplayLabel(profile, pageAiCurrentProfile()); - return descriptorLabel || pageAiAgentRecord(agentId).label; - } - - function pageAiRenderAgentProfileOption(agentId, profile, label, detail) { - var profileId = pageAiProfileValue(profile); - var active = pageAiCurrentAgentId() === agentId && profileId === pageAiCurrentProfile(); - return '' + - ''; - } - - function pageAiAgentDescriptorDetail(descriptor) { - if (!descriptor) return ''; - var capabilities = pageAiNormalizeArray(descriptor.capabilities); - var toolCount = Number(descriptor.toolCount || pageAiNormalizeArray(descriptor.tools).length || 0); - var parts = []; - parts.push(descriptor.canWriteFiles === false ? '只读/聊天' : '可写授权文件'); - if (capabilities.length) parts.push('capability ' + capabilities.length); - if (toolCount) parts.push('tool ' + toolCount); - return parts.join(' · '); - } - - function pageAiRenderDescriptorField(field) { - var key = String(field && field.key || '').trim(); - if (!key || field.secret === true) return ''; - var label = String(field.label || key).trim(); - var kind = String(field.kind || 'string').trim(); - var value = pageAiDescriptorFieldValue(field); - var options = pageAiDescriptorFieldOptions(field); - var control = ''; - if (kind === 'select' || options.length) { - control = ''; - } else if (kind === 'boolean' || kind === 'toggle') { - control = ''; - } else { - control = ''; - } - return '' + - ''; - } - - function pageAiReasonixQuickControlLabel(key, fallback) { - if (key === 'ai.agent.reasonix.model_id') return '模型'; - if (key === 'ai.agent.reasonix.approval_mode') return '审批'; - if (key === 'ai.agent.reasonix.plan_mode') return 'Plan'; - return fallback || key; - } - - function pageAiRenderReasonixQuickControl(field) { - var key = String(field && field.key || '').trim(); - if (!key || field.secret === true) return ''; - var value = pageAiDescriptorFieldValue(field); - var options = pageAiDescriptorFieldOptions(field); - if (!options.length) return ''; - return '' + - ''; - } - - function pageAiReasonixQuickControlsHtml() { - if (pageAiCurrentAgentId() !== 'reasonix') return ''; - var descriptor = pageAiAgentDescriptor('reasonix') || pageAiAgentRecord('reasonix'); - var wanted = { - 'ai.agent.reasonix.model_id': true, - 'ai.agent.reasonix.approval_mode': true, - 'ai.agent.reasonix.plan_mode': true - }; - var fields = pageAiNormalizeArray(descriptor && descriptor.fields) - .filter(function(field) { return wanted[String(field && field.key || '').trim()] === true; }) - .map(pageAiRenderReasonixQuickControl) - .filter(Boolean) - .join(''); - if (!fields) return ''; - return '' + - '
    ' + - fields + - '
    '; - } - - function pageAiRenderAgentDescriptorCard(agentId, fallbackTitle) { - var descriptor = pageAiAgentDescriptor(agentId) || pageAiAgentRecord(agentId); - var title = String(descriptor && (descriptor.displayName || descriptor.label) || fallbackTitle || agentId).trim(); - var fields = pageAiNormalizeArray(descriptor && descriptor.fields).map(pageAiRenderDescriptorField).filter(Boolean).join(''); - var disabled = pageAiNormalizeArray(descriptor && descriptor.disabledCapabilities); - var disabledText = disabled.length ? '
    disabled: ' + escapeHtml(disabled.join(', ')) + '
    ' : ''; - return '' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(title) + '
    ' + escapeHtml(pageAiAgentDescriptorDetail(descriptor) || 'descriptor fallback') + '
    ' + - '
    ' + - disabledText + - (fields ? '
    ' + fields + '
    ' : '
    当前 agent 没有可配置字段。
    ') + - '
    '; - } - - function pageAiCurrentModelLabel() { - var profile = pageAiCurrentProfileRecord(); - var prefs = pageUiState.pageAiSkillPreferences || {}; - var toolModel = pageAiMnoteToolModel(); - var reasonixModel = String(prefs['ai.agent.reasonix.model_id'] || '').trim(); - var approvalMode = String(prefs['ai.agent.reasonix.approval_mode'] || 'ask').trim() || 'ask'; - var planMode = String(prefs['ai.agent.reasonix.plan_mode'] || 'manual').trim() || 'manual'; - var approvalLabel = { - ask: '审批:询问', - allow: '审批:自动允许', - deny: '审批:自动拒绝' - }[approvalMode] || ('审批:' + approvalMode); - var planLabel = { - manual: '计划:手动', - auto: '计划:自动' - }[planMode] || ('计划:' + planMode); - if (!profile) return 'tool: ' + toolModel; - var model = String(profile.model || '').trim(); - var gateway = String(profile.gateway || '').trim(); - var profileLabel = [model, gateway].filter(Boolean).join(' · ') || '由 Hermes 决定'; - if (pageAiCurrentAgentId() === 'reasonix') { - return ['Reasonix', reasonixModel || toolModel, approvalLabel, planLabel].filter(Boolean).join(' · '); - } - return 'tool: ' + toolModel + ' · profile: ' + profileLabel; - } - - function pageAiFormatChangedFiles(files) { - return pageAiNormalizeArray(files).map(function(file) { - var path = String(file && file.path || '').trim(); - var changeType = String(file && file.changeType || file.change_type || 'modified').trim(); - var summary = String(file && file.summary || '').trim(); - var version = String(file && (file.version || file.revision || '') || '').trim(); - var hashBefore = String(file && file.hashBefore || '').trim(); - var hashAfter = String(file && file.hashAfter || '').trim(); - if (!version && (hashBefore || hashAfter)) version = 'hash ' + [hashBefore || '0', hashAfter || '0'].join('→'); - var modifiedBefore = Number(file && file.modifiedBeforeMs || 0) || 0; - var modifiedAfter = Number(file && file.modifiedAfterMs || 0) || 0; - if (!version && (modifiedBefore || modifiedAfter)) version = 'mtime ' + [String(modifiedBefore), String(modifiedAfter)].join('→'); - var actor = [file && file.agentKind, file && file.actorType, file && file.actorId].map(function(value) { - return String(value || '').trim(); - }).filter(Boolean).join('/'); - return [changeType, path, version, summary, actor].filter(Boolean).join(' · '); - }).filter(Boolean).join('\n'); - } - - function pageAiRunStatusLabel(status) { - if (status === 'queued') return '排队中'; - if (status === 'running') return '运行中'; - if (status === 'tool_calling') return '调用工具'; - if (status === 'completed') return '已完成'; - if (status === 'failed') return '失败'; - if (status === 'aborted') return '已停止'; - return '空闲'; - } - - function pageAiMemoryFileLabel(section) { - if (section === 'soul') return 'SOUL.md'; - if (section === 'user') return 'USER.md'; - return 'MEMORY.md'; - } - - function pageAiContextScopeLabel(scope) { - if (scope === 'selection') return '当前选区'; - if (scope === 'block') return '当前块'; - if (scope === 'options') return '页面设置'; - return '当前页'; - } - - function pageAiFilteredSkillEntries() { - var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase(); - var parts = pageAiSkillSourceParts(pageAiCurrentSkillSource()); - var entries = pageAiToggleableSkillEntries(parts.group, parts.profile); - if (!entries.length) { - entries = pageAiSkillListEntries().map(function(skill) { - return Object.assign({}, skill, { - group: parts.group, - profile: parts.profile || '' - }); - }); - } - return entries.filter(function(skill) { - if (parts.group === 'hermes' && pageAiHideHermesBuiltinSkills(parts.profile) && skill.builtin === true) return false; - if (!query) return true; - return String(skill.name || skill.id || '').toLowerCase().indexOf(query) >= 0 - || String(skill.title || '').toLowerCase().indexOf(query) >= 0 - || String(skill.description || '').toLowerCase().indexOf(query) >= 0 - || String(skill.category || '').toLowerCase().indexOf(query) >= 0 - || pageAiSkillOriginLabel(skill).toLowerCase().indexOf(query) >= 0; - }); - } - - function pageAiRuntimeStatusValue(path, fallback) { - var node = pageUiState.pageAiRuntimeStatus || {}; - String(path || '').split('.').forEach(function(part) { - if (!part || node == null || typeof node !== 'object') return; - node = node[part]; - }); - if (node == null || node === '') return fallback || ''; - return String(node); - } - - function pageAiRuntimeStatusLabel() { - var status = pageUiState.pageAiRuntimeStatus || {}; - var runtime = String(status.runtime || pageUiState.pageAiAcpRuntime || 'reasonix'); - var mode = String(status.mode || 'new_session'); - if (runtime === 'reasonix') return pageAiCurrentModelLabel() + ' · ' + mode.replace(/_/g, '-'); - return runtime + ' · ' + mode.replace(/_/g, '-'); - } - - function pageAiRuntimeDetailHtml() { - var status = pageUiState.pageAiRuntimeStatus || {}; - var acp = status.acp && typeof status.acp === 'object' ? status.acp : {}; - var roots = status.roots && typeof status.roots === 'object' ? status.roots : {}; - var tools = status.tools && typeof status.tools === 'object' ? status.tools : {}; - var mcp = status.mcp && typeof status.mcp === 'object' ? status.mcp : {}; - var queue = status.session && status.session.queue && typeof status.session.queue === 'object' ? status.session.queue : {}; - var logs = status.logs && Array.isArray(status.logs.items) ? status.logs.items : []; - var rows = [ - ['runtime', pageAiRuntimeStatusLabel()], - ['session', String(status.mnoteSessionId || pageUiState.pageAiActiveSessionId || '无')], - ['acpSessionId', String(acp.acpSessionId || '无')], - ['load/replay', String(acp.loadStatus || 'not_applicable') + ' / replay=' + String(acp.replaySeen === true)], - ['live binding', acp.liveBinding && typeof acp.liveBinding === 'object' ? String(acp.liveBinding.status || 'unknown') : '无'], - ['roots', 'write=' + String(roots.write || 0) + ' readOnly=' + String(roots.readOnly || 0)], - ['MCP/tools', String(mcp.status || 'unknown') + ' · enabled=' + String(tools.enabled || 0) + ' disabled=' + String(tools.disabled || 0)], - ['queue', String(queue.queuedCount || pageUiState.pageAiQueueLength || 0)], - ['jobs', pageAiRuntimeStatusValue('jobs.running', '0')] - ]; - var html = rows.map(function(row) { - return '
    ' + escapeHtml(row[0]) + '' + escapeHtml(row[1]) + '
    '; - }).join(''); - if (pageUiState.pageAiRuntimeStatusError) { - html += '
    ' + escapeHtml(pageUiState.pageAiRuntimeStatusError) + '
    '; - } - if (logs.length) { - html += logs.slice(-4).map(function(item) { - return '
    ' + escapeHtml(String(item.kind || 'log')) + '' + escapeHtml(String(item.seq || '')) + '
    '; - }).join(''); - } - return html || '
    Runtime status 尚未加载。
    '; - } - - function pageAiUsageDetailHtml() { - var status = pageUiState.pageAiRuntimeStatus || {}; - var usage = status.usage && typeof status.usage === 'object' ? status.usage : {}; - var rows = [ - ['last run', usage.lastRunTokens || usage.lastRunCost || 'unknown'], - ['session total', usage.sessionTokens || usage.sessionCost || 'unknown'], - ['cache', usage.cacheTokens || usage.cacheHit || 'unknown'] - ]; - return rows.map(function(row) { - return '
    ' + escapeHtml(row[0]) + '' + escapeHtml(String(row[1])) + '
    '; - }).join(''); - } - - function pageAiSettingsTabsHtml(activeTab) { - return [ - ['agent', 'MNote'], - ['reasonix-settings', 'Reasonix'], - ['hermes-settings', 'Hermes'] - ].map(function(tab) { - var active = tab[0] === activeTab; - return ''; - }).join(''); - } - - function pageAiStatusTabsHtml() { - return '' + - '' + - '' + - ''; - } - - function renderPageAiProviderButtons() { - var drawer = ensurePageAiDrawer(); - if (drawer.getAttribute('data-page-ai-opencode-host') === 'true') return; - var boardFirst = isPageAiBoardFirstEnabled(); - var isAcp = pageUiState.pageAiAcpRuntime !== ''; - drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) { - var provider = button.getAttribute('data-page-ai-provider') || 'hermes'; - var active = provider === pageUiState.pageAiProvider; - button.classList.toggle('is-active', active); - button.setAttribute('aria-pressed', active ? 'true' : 'false'); - }); - var providerNode = drawer.querySelector('.wolai-page-ai-subtitle span:first-child'); - if (providerNode instanceof HTMLElement) { - providerNode.textContent = boardFirst ? 'Agent Board' : pageAiAgentRecord(pageAiCurrentAgentId()).label; - } - } - - function renderPageAiControls() { - var drawer = ensurePageAiDrawer(); - if (drawer.getAttribute('data-page-ai-opencode-host') === 'true') return; - var boardFirst = isPageAiBoardFirstEnabled(); - var isAcp = pageUiState.pageAiAcpRuntime !== ''; - var activeAgentId = pageAiCurrentAgentId(); - var activeProfile = pageAiCurrentProfile(); - if (pageUiState.pageAiPage === 'runtime') pageUiState.pageAiPage = 'status'; - if (pageUiState.pageAiPage === 'chat-only-settings') pageUiState.pageAiPage = 'agent'; - drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat'); - drawer.setAttribute('data-page-ai-active-agent-id', activeAgentId); - drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || 'reasonix'); - documentRef.documentElement.setAttribute('data-mnote-page-ai-agent-id', activeAgentId); - var agentButton = drawer.querySelector('[data-page-ai-agent-button]'); - var agentPopoverId = 'mnote-page-ai-agent-popover'; - var activeAgentLabel = boardFirst && typeof pageAiBoardWorkerLabel === 'function' - ? pageAiBoardWorkerLabel() + ' / ' + (typeof pageAiBoardModelLabel === 'function' ? pageAiBoardModelLabel() : '默认') - : pageAiCurrentAgentSelectionLabel(); - if (agentButton instanceof HTMLElement) { - agentButton.textContent = boardFirst ? 'Board' : 'AI'; - agentButton.setAttribute('aria-expanded', pageUiState.pageAiAgentPopoverOpen ? 'true' : 'false'); - agentButton.setAttribute('aria-controls', agentPopoverId); - agentButton.setAttribute('data-page-ai-agent-summary', activeAgentLabel); - agentButton.setAttribute('aria-label', (boardFirst ? '运行控制:' : 'Agent:') + activeAgentLabel); - agentButton.setAttribute('title', (boardFirst ? '运行控制:' : 'Agent:') + activeAgentLabel); - } - var agentPopover = drawer.querySelector('[data-page-ai-agent-popover]'); - if (agentPopover instanceof HTMLElement) { - agentPopover.id = agentPopoverId; - agentPopover.hidden = !pageUiState.pageAiAgentPopoverOpen; - if (boardFirst) { - var selectedWorkerId = typeof pageAiBoardSelectedWorkerId === 'function' ? pageAiBoardSelectedWorkerId() : 'mnote-page-ai-zcode'; - var selectedWorkflowId = typeof pageAiBoardSelectedWorkflowId === 'function' ? pageAiBoardSelectedWorkflowId() : 'builtin-mnote-page-ai-chat'; - var workerOptions = typeof pageAiBoardWorkerOptions === 'function' ? pageAiBoardWorkerOptions() : []; - var workflowOptions = typeof pageAiBoardWorkflowOptions === 'function' ? pageAiBoardWorkflowOptions() : []; - var modelOptions = typeof pageAiBoardModelOptions === 'function' ? pageAiBoardModelOptions(selectedWorkerId) : []; - var selectedModelOverride = typeof pageAiBoardSelectedModelOverride === 'function' ? pageAiBoardSelectedModelOverride() : 'zcode-default'; - var workerSelect = workerOptions.length > 1 ? '' : '
    Worker' + escapeHtml(typeof pageAiBoardWorkerLabel === 'function' ? pageAiBoardWorkerLabel() : 'MNote 页面 AI · ZCode') + '
    '; - var workflowSelect = workflowOptions.length > 1 ? '' : '
    Workflow' + escapeHtml(typeof pageAiBoardWorkflowLabel === 'function' ? pageAiBoardWorkflowLabel() : 'MNote 页面 AI') + '
    '; - var modelSelect = ''; - agentPopover.innerHTML = '' + - '
    ' + - 'Agent Board' + - '' + - '
    ' + - '
    ' + workerSelect + modelSelect + workflowSelect + '
    ' + - '
    MNote 只发送 capability/envelope;provider、执行和 workflow 由 Agent Board 负责。
    '; - } else { - var chatOnlyOptions = pageAiChatOnlyProfileEntries().map(function(profile) { - var spec = pageAiChatOnlyProfileSpec(profile); - var label = profile.menuLabel || (spec && spec.label) || pageAiProfileDisplayLabel(profile, ''); - var providerKind = String(profile.providerKind || (spec && spec.providerKind) || '').trim(); - var detail = providerKind === 'api-chat' ? 'API 聊天 · 不申请文件写权限' : '网页问答 · 不申请文件写权限'; - return pageAiRenderAgentProfileOption('chat_only', profile, label, detail); - }).join(''); - var hermesOptions = pageAiHermesProfileEntries().map(function(profile) { - var profileId = pageAiProfileValue(profile); - var scope = profile.kind === 'shared' - ? (profile.readonly ? '共享 · 只读配置' : '共享') - : (profile.kind === 'personal' ? '个人 profile' : 'Hermes profile'); - return pageAiRenderAgentProfileOption('hermes', profile, pageAiProfileDisplayLabel(profile, profileId), scope); - }).join(''); - var reasonixActive = activeAgentId === 'reasonix'; - var reasonixDescriptor = pageAiAgentDescriptor('reasonix'); - var reasonixLabel = String(reasonixDescriptor && (reasonixDescriptor.displayName || reasonixDescriptor.label) || 'Reasonix').trim() || 'Reasonix'; - var reasonixDetail = pageAiAgentDescriptorDetail(reasonixDescriptor) || '可读取并在授权目录内写文件'; - agentPopover.innerHTML = '' + - '
    ' + - '选择 Agent' + - '' + - '
    ' + - '
    ' + - '
    ChatOnly
    ' + - '
    ' + chatOnlyOptions + '
    ' + - '
    ' + - '
    ' + - '
    Hermes
    ' + - '
    ' + hermesOptions + '
    ' + - '
    ' + - '
    ' + - '
    Reasonix
    ' + - '
    ' + - '' + - '
    ' + - '
    '; - } - } - var agentChip = drawer.querySelector('[data-page-ai-agent-chip]'); - if (agentChip instanceof HTMLElement) { - agentChip.textContent = boardFirst && typeof pageAiBoardWorkerLabel === 'function' ? pageAiBoardWorkerLabel() + ' / ' + (typeof pageAiBoardModelLabel === 'function' ? pageAiBoardModelLabel() : '默认') : activeAgentLabel; - agentChip.setAttribute('title', boardFirst ? '当前 worker/model:' + agentChip.textContent : '当前 Agent:' + activeAgentLabel); - } - var contextButton = drawer.querySelector('[data-page-ai-context-button]'); - var contextPopoverId = 'mnote-page-ai-context-popover'; - if (contextButton instanceof HTMLElement) { - var summary = pageAiContextButtonSummary(); - contextButton.textContent = '⇅'; - contextButton.setAttribute('aria-expanded', pageUiState.pageAiContextPopoverOpen ? 'true' : 'false'); - contextButton.setAttribute('aria-controls', contextPopoverId); - contextButton.setAttribute('data-page-ai-context-summary', summary); - contextButton.setAttribute('aria-label', '上下文:' + summary); - contextButton.setAttribute('title', '上下文:' + summary); - } - var contextPopover = drawer.querySelector('[data-page-ai-context-popover]'); - if (contextPopover instanceof HTMLElement) { - var selectedRefs = pageAiEnsureContextRefState(); - contextPopover.id = contextPopoverId; - contextPopover.hidden = !pageUiState.pageAiContextPopoverOpen; - contextPopover.innerHTML = '' + - '
    ' + - '发送给 AI 的上下文' + - '' + - '
    ' + - '
    ' + - PAGE_AI_CONTEXT_REF_REGISTRY.map(function(ref) { - var checked = selectedRefs[ref.id] === true; - var disabled = pageAiContextRefDisabled(ref.id); - return '' + - ''; - }).join('') + - '
    ' + - '
    ' + - '授权区域' + - '' + escapeHtml(pageAiBuildAllowedRoots().length ? pageAiBuildAllowedRoots().map(function(root) { return root.permission + ' · ' + root.rootUri.replace(/^file:\/\//, ''); }).join(' / ') : '未选择授权区域') + '' + - '
    '; - } - var targetButton = drawer.querySelector('[data-page-ai-target-button]'); - var targetPopover = drawer.querySelector('[data-page-ai-target-popover]'); - var targetChip = drawer.querySelector('[data-page-ai-target-chip]'); - var targetPopoverId = 'mnote-page-ai-target-popover'; - var targetCandidates = pageAiEditorTargetCandidates(); - var activeTarget = currentPageAiEditorTarget(); - var activeTargetLabel = pageAiTargetLabel(activeTarget); - if (targetButton instanceof HTMLElement) { - targetButton.textContent = '◎'; - targetButton.setAttribute('aria-expanded', pageUiState.pageAiTargetPopoverOpen ? 'true' : 'false'); - targetButton.setAttribute('aria-controls', targetPopoverId); - targetButton.setAttribute('aria-label', '目标:' + activeTargetLabel); - targetButton.setAttribute('title', '目标:' + activeTargetLabel); - } - if (targetChip instanceof HTMLElement) { - targetChip.textContent = activeTargetLabel; - targetChip.setAttribute('title', '当前目标:' + activeTargetLabel + ' · ' + pageAiTargetDetail(activeTarget)); - } - if (targetPopover instanceof HTMLElement) { - targetPopover.id = targetPopoverId; - targetPopover.hidden = !pageUiState.pageAiTargetPopoverOpen; - targetPopover.innerHTML = '' + - '
    ' + - '选择写入目标' + - '' + - '
    ' + - '
    ' + - targetCandidates.map(function(target) { - var targetId = String(target && target.targetId || '').trim(); - var active = targetId && targetId === String(activeTarget && activeTarget.targetId || '').trim(); - return '' + - ''; - }).join('') + - '
    '; - } - var allowedRootsNode = drawer.querySelector('[data-page-ai-allowed-roots]'); - if (allowedRootsNode instanceof HTMLElement) { - var allowedRoots = pageAiBuildAllowedRoots(); - if (!allowedRoots.length) { - var errorText = String(pageUiState.pageAiAllowedRootsError || '').trim(); - allowedRootsNode.innerHTML = '' + escapeHtml(errorText || '未选择授权区域') + ''; - } else { - allowedRootsNode.innerHTML = allowedRoots.map(function(root) { - var label = root.rootUri.replace(/^file:\/\//, '') || root.rootUri; - return '' + escapeHtml(root.permission + ' · ' + label) + ''; - }).join(''); - } - } - // Populate ACP runtime dropdown - var acpSelect = drawer.querySelector('[data-page-ai-acp-runtime]'); - if (acpSelect instanceof HTMLSelectElement) { - var runtimes = pageUiState.pageAiAcpRuntimes.length ? pageUiState.pageAiAcpRuntimes : pageAiNormalizeAcpRuntimes([]); - acpSelect.innerHTML = runtimes.map(function(rt) { - return ''; - }).join(''); - acpSelect.value = pageUiState.pageAiAcpRuntime || 'reasonix'; - } - // Show/hide Hermes-specific profile select - var profileLabel = drawer.querySelector('[data-page-ai-hermes-profile]'); - if (profileLabel instanceof HTMLElement) { - profileLabel.style.display = pageUiState.pageAiAcpRuntime === 'reasonix' ? 'none' : ''; - } - // When ACP is selected, populate agent panel with ACP runtime info - var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]'); - if (agentPanel instanceof HTMLElement) { - agentPanel.innerHTML = '' + - (pageUiState.pageAiAgentDescriptorError ? '
    ' + escapeHtml(pageUiState.pageAiAgentDescriptorError) + '
    ' : '') + - '
    ' + - '
    MNote
    上下文、权限、工具、会话历史和审计
    ' + - '
    这里配置 MNote 发送给 agent 的 envelope,不配置 Reasonix 或 Hermes 原生 runtime。
    ' + - '
    ' + - '
    ' + - '
    授权区域
    SQLite directory_grants
    ' + - '
    ' + - '
    ' + - '
    ' + - '
    默认上下文
    ContextRefs
    ' + - '
    由 MNote 生成 run envelope,不写入 Reasonix/Hermes native memory。
    ' + - '
    ' + - '
    ' + - '
    会话与审计
    history / export / changed files
    ' + - '
    删除会话只删除 MNote history;外部 Hermes/provider 会话不会默认删除。
    ' + - '
    '; - var mnoteAllowedRootsNode = agentPanel.querySelector('[data-page-ai-allowed-roots]'); - if (mnoteAllowedRootsNode instanceof HTMLElement) { - var mnoteAllowedRoots = pageAiBuildAllowedRoots(); - mnoteAllowedRootsNode.innerHTML = mnoteAllowedRoots.length - ? mnoteAllowedRoots.map(function(root) { - var label = root.rootUri.replace(/^file:\/\//, '') || root.rootUri; - return '' + escapeHtml(root.permission + ' · ' + label) + ''; - }).join('') - : '' + escapeHtml(String(pageUiState.pageAiAllowedRootsError || '').trim() || '未选择授权区域') + ''; - } - } - var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]'); - if (profileSummary instanceof HTMLElement) profileSummary.textContent = '上下文可选'; - var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]'); - if (profileSummary instanceof HTMLElement) profileSummary.textContent = '上下文可选'; - drawer.querySelectorAll('[data-page-ai-profile-select]').forEach(function(profileSelect) { - if (!(profileSelect instanceof HTMLSelectElement)) return; - var profiles = pageUiState.pageAiProfiles.length ? pageUiState.pageAiProfiles : [{ name: activeProfile, active: true }]; - profileSelect.innerHTML = profiles.map(function(profile) { - var name = pageAiProfileValue(profile) || 'default'; - var model = [profile.model, profile.gateway].filter(Boolean).join(' / '); - var scope = profile.kind === 'shared' - ? (profile.readonly ? 'shared · readonly' : 'shared · admin') - : (profile.kind === 'personal' ? 'personal · 可配置' : ''); - var label = [profile.alias || name, scope, model].filter(Boolean).join(' · '); - return ''; - }).join(''); - profileSelect.value = activeProfile; - }); - var runStatus = drawer.querySelector('[data-page-ai-run-status]'); - if (runStatus instanceof HTMLElement) { - var queueSuffix = pageUiState.pageAiQueueLength > 0 ? ' · 队列 ' + pageUiState.pageAiQueueLength : ''; - runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus) + queueSuffix; - } - drawer.querySelectorAll('[data-page-ai-runtime-badge]').forEach(function(runtimeBadge) { - if (runtimeBadge instanceof HTMLElement) { - var workflowLabel = boardFirst && typeof pageAiBoardWorkflowLabel === 'function' ? pageAiBoardWorkflowLabel() : ''; - runtimeBadge.textContent = boardFirst ? 'Agent Board · ' + workflowLabel : pageAiRuntimeStatusLabel(); - } - }); - var runtimeActive = drawer.querySelector('[data-page-ai-runtime-active-run]'); - if (runtimeActive instanceof HTMLElement) { - var activeRunStatus = pageAiRuntimeStatusValue('activeRun.status', pageUiState.pageAiRunStatus || 'idle'); - runtimeActive.textContent = pageAiRunStatusLabel(activeRunStatus); - } - var runtimeAcp = drawer.querySelector('[data-page-ai-runtime-acp]'); - if (runtimeAcp instanceof HTMLElement) runtimeAcp.textContent = boardFirst ? 'provider 由 Board worker 决定' : pageAiRuntimeStatusValue('acp.acpSessionId', '无 ACP session'); - var runtimeDetail = drawer.querySelector('[data-page-ai-runtime-detail]'); - if (runtimeDetail instanceof HTMLElement) runtimeDetail.innerHTML = pageAiRuntimeDetailHtml(); - var jobsList = drawer.querySelector('[data-page-ai-jobs-list]'); - if (jobsList instanceof HTMLElement) { - var runtimeStatus = pageUiState.pageAiRuntimeStatus || {}; - var jobs = runtimeStatus.jobs && Array.isArray(runtimeStatus.jobs.items) ? runtimeStatus.jobs.items : []; - jobsList.innerHTML = jobs.length - ? jobs.map(function(job) { - return '
    ' + escapeHtml(String(job.id || job.name || 'job')) + '' + escapeHtml(String(job.status || 'running')) + '
    '; - }).join('') - : '
    暂无运行中的 job。
    '; - } - var logsList = drawer.querySelector('[data-page-ai-logs-list]'); - if (logsList instanceof HTMLElement) { - var logStatus = pageUiState.pageAiRuntimeStatus || {}; - var logs = logStatus.logs && Array.isArray(logStatus.logs.items) ? logStatus.logs.items : []; - logsList.innerHTML = logs.length - ? logs.slice(-8).map(function(item) { - return '
    ' + escapeHtml(String(item.kind || 'log')) + '' + escapeHtml(String(item.seq || '')) + '
    '; - }).join('') - : '
    暂无 runtime logs。
    '; - } - var usageDetail = drawer.querySelector('[data-page-ai-usage-detail]'); - if (usageDetail instanceof HTMLElement) usageDetail.innerHTML = pageAiUsageDetailHtml(); - var canStop = ['queued', 'running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0 && pageUiState.pageAiCurrentRunId; - drawer.querySelectorAll('[data-page-ai-action="stop-run"]').forEach(function(stopButton) { - if (!(stopButton instanceof HTMLButtonElement)) return; - stopButton.disabled = !canStop; - stopButton.setAttribute('aria-disabled', canStop ? 'false' : 'true'); - }); - var settingsLink = drawer.querySelector('[data-page-ai-action="open-hermes-settings"]'); - if (settingsLink instanceof HTMLButtonElement) { - settingsLink.disabled = !pageAiHermesSettingsUrl(); - settingsLink.title = pageAiHermesSettingsUrl() ? '打开 Hermes 设置' : '未配置 MNOTE_WEB_HERMES_UPSTREAM_URL'; - } - var queueList = drawer.querySelector('[data-page-ai-queue-list]'); - if (queueList instanceof HTMLElement) { - if (!pageUiState.pageAiQueuedItems.length) { - queueList.innerHTML = '
    暂无排队项。
    '; - } else { - queueList.innerHTML = pageUiState.pageAiQueuedItems.map(function(item, index) { - var label = '队列 ' + (index + 1); - var preview = String(item.preview || item.traceId || item.queueId || '').trim(); - return '' + - '
    ' + - '
    ' + escapeHtml(label) + '
    ' + escapeHtml(preview || item.queueId) + '
    ' + - '' + - '
    '; - }).join(''); - } - } - var sessionNode = drawer.querySelector('[data-page-ai-session-status]'); - if (sessionNode instanceof HTMLElement) { - var session = pageAiCurrentSession(); - var usageText = session && session.usage ? pageAiUsageSummary(session.usage) : ''; - sessionNode.textContent = session && session.id - ? [String(session.title || '当前页问答'), pageAiSessionStorageLabel(session), usageText].filter(Boolean).join(' · ') - : '等待 AI session'; - } - var modelNode = drawer.querySelector('[data-page-ai-model-status]'); - if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel(); - var reasonixControls = drawer.querySelector('[data-page-ai-reasonix-quick-controls]'); - if (reasonixControls instanceof HTMLElement) { - if (pageAiCurrentAgentId() === 'reasonix') { - reasonixControls.innerHTML = pageAiReasonixQuickControlsHtml().replace(/^]*>|<\/div>$/g, ''); - reasonixControls.hidden = false; - } else { - reasonixControls.innerHTML = ''; - reasonixControls.hidden = true; - } - } - var scopeSelect = drawer.querySelector('[data-page-ai-context-scope]'); - if (scopeSelect instanceof HTMLSelectElement) scopeSelect.value = pageUiState.pageAiContextScope; - var scopeLabel = drawer.querySelector('[data-page-ai-context-scope-label]'); - if (scopeLabel instanceof HTMLElement) scopeLabel.textContent = pageAiContextScopeLabel(pageUiState.pageAiContextScope); - drawer.querySelectorAll('[data-page-ai-tab]').forEach(function(button) { - var target = button.getAttribute('data-page-ai-tab') || 'chat'; - var active = target === pageUiState.pageAiPage; - button.classList.toggle('is-active', active); - button.setAttribute('aria-selected', active ? 'true' : 'false'); - }); - drawer.querySelectorAll('[data-page-ai-panel]').forEach(function(panel) { - if (panel instanceof HTMLElement) { - panel.hidden = panel.getAttribute('data-page-ai-panel') !== pageUiState.pageAiPage; - } - }); - if (boardFirst) { - var subtitle = drawer.querySelector('.wolai-page-ai-subtitle'); - if (subtitle instanceof HTMLElement) { - var workerLabel = typeof pageAiBoardWorkerLabel === 'function' ? pageAiBoardWorkerLabel() : 'ZCode 默认 developer'; - subtitle.innerHTML = 'Agent Board' + escapeHtml(workerLabel) + 'local-first 文件编辑'; - } - drawer.querySelectorAll('[data-page-ai-tab="agent"], [data-page-ai-tab="reasonix-settings"], [data-page-ai-tab="hermes-settings"]').forEach(function(node) { - if (node instanceof HTMLElement) node.hidden = true; - }); - drawer.querySelectorAll('[data-page-ai-panel="agent"], [data-page-ai-panel="reasonix-settings"], [data-page-ai-panel="hermes-settings"]').forEach(function(node) { - if (node instanceof HTMLElement) node.setAttribute('data-page-ai-legacy-provider-panel', 'true'); - }); - } - var profileError = drawer.querySelector('[data-page-ai-profile-error]'); - if (profileError instanceof HTMLElement) { - profileError.textContent = pageUiState.pageAiProfileError || ''; - profileError.hidden = !pageUiState.pageAiProfileError; - } - var memoryError = drawer.querySelector('[data-page-ai-memory-error]'); - if (memoryError instanceof HTMLElement) { - var memoryErrorText = pageUiState.pageAiProfileMemoryError || ''; - if (memoryErrorText && memoryErrorText === pageUiState.pageAiProfileError) memoryErrorText = ''; - memoryError.textContent = memoryErrorText; - memoryError.hidden = !memoryErrorText; - } - var hermesMemoryPanel = drawer.querySelector('[data-page-ai-hermes-panel]'); - if (hermesMemoryPanel instanceof HTMLElement) { - hermesMemoryPanel.innerHTML = pageAiRenderAgentDescriptorCard('hermes', 'Hermes') + ['soul', 'user', 'memory'].map(function(section) { - var label = pageAiMemoryFileLabel(section); - var value = pageUiState.pageAiProfileMemoryDrafts[section] || ''; - return '' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(label) + '
    ' + - '
    保存到 Hermes profile: ' + escapeHtml(activeProfile) + '
    ' + - '
    ' + - '' + - '
    ' + - '' + - '
    '; - }).join(''); - } - var skillError = drawer.querySelector('[data-page-ai-skill-error]'); - if (skillError instanceof HTMLElement) { - skillError.textContent = pageUiState.pageAiSkillError || ''; - skillError.hidden = !pageUiState.pageAiSkillError; - } - var sessionError = drawer.querySelector('[data-page-ai-session-error]'); - if (sessionError instanceof HTMLElement) { - sessionError.textContent = pageUiState.pageAiSessionError || ''; - sessionError.hidden = !pageUiState.pageAiSessionError; - } - var selectedSessionCount = pageAiSelectedSessionCount(); - var selectedSessionToolbar = drawer.querySelector('[data-page-ai-history-selection-toolbar]'); - if (selectedSessionToolbar instanceof HTMLElement) { - selectedSessionToolbar.hidden = selectedSessionCount <= 0; - } - var selectedSessionCountNode = drawer.querySelector('[data-page-ai-history-selection-count]'); - if (selectedSessionCountNode instanceof HTMLElement) { - selectedSessionCountNode.textContent = '已选 ' + String(selectedSessionCount) + ' 个'; - } - var sessionSearch = drawer.querySelector('[data-page-ai-session-search]'); - if (sessionSearch instanceof HTMLInputElement && documentRef.activeElement !== sessionSearch) { - sessionSearch.value = pageUiState.pageAiSessionSearchQuery || ''; - } - var sessionAgentFilter = drawer.querySelector('[data-page-ai-session-agent-filter]'); - if (sessionAgentFilter instanceof HTMLSelectElement) { - var historyRowsForFilter = pageUiState.pageAiSessionSearchResults.length - ? pageUiState.pageAiSessionSearchResults - : pageUiState.pageAiSessions; - var options = pageAiSessionAgentFilterOptions(historyRowsForFilter); - var activeFilter = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all'; - if (!options.some(function(option) { return option.value === activeFilter; })) activeFilter = 'all'; - sessionAgentFilter.innerHTML = options.map(function(option) { - return ''; - }).join(''); - sessionAgentFilter.value = activeFilter; - pageUiState.pageAiSessionAgentFilter = activeFilter; - } - var sessionStatusFilter = drawer.querySelector('[data-page-ai-session-status-filter]'); - if (sessionStatusFilter instanceof HTMLSelectElement) { - var historyRowsForStatusFilter = pageUiState.pageAiSessionSearchResults.length - ? pageUiState.pageAiSessionSearchResults - : pageUiState.pageAiSessions; - var statusOptions = pageAiSessionStatusFilterOptions(historyRowsForStatusFilter); - var activeStatusFilter = String(pageUiState.pageAiSessionStatusFilter || 'all').trim() || 'all'; - if (!statusOptions.some(function(option) { return option.value === activeStatusFilter; })) activeStatusFilter = 'all'; - sessionStatusFilter.innerHTML = statusOptions.map(function(option) { - return ''; - }).join(''); - sessionStatusFilter.value = activeStatusFilter; - pageUiState.pageAiSessionStatusFilter = activeStatusFilter; - } - var skillSearch = drawer.querySelector('[data-page-ai-skill-search]'); - if (skillSearch instanceof HTMLInputElement && documentRef.activeElement !== skillSearch) { - skillSearch.value = pageUiState.pageAiSkillQuery; - } - var skillSourceSelect = drawer.querySelector('[data-page-ai-skill-source-select]'); - if (skillSourceSelect instanceof HTMLSelectElement) { - var activeSkillSource = pageAiCurrentSkillSource(); - var sourceOptions = pageAiSkillSourceOptions(); - skillSourceSelect.innerHTML = sourceOptions.map(function(option) { - return ''; - }).join(''); - skillSourceSelect.value = activeSkillSource; - var sourceControl = skillSourceSelect.closest('[data-page-ai-skill-source-control]'); - if (sourceControl instanceof HTMLElement) sourceControl.hidden = sourceOptions.length <= 1; - } - var hermesBuiltinToggle = drawer.querySelector('[data-page-ai-hide-hermes-builtin]'); - if (hermesBuiltinToggle instanceof HTMLInputElement) { - var skillSourceParts = pageAiSkillSourceParts(pageAiCurrentSkillSource()); - var showHermesFilter = skillSourceParts.group === 'hermes'; - var hermesFilterLabel = hermesBuiltinToggle.closest('.wolai-page-ai-skill-filter-toggle'); - if (hermesFilterLabel instanceof HTMLElement) hermesFilterLabel.hidden = !showHermesFilter; - hermesBuiltinToggle.checked = showHermesFilter && pageAiHideHermesBuiltinSkills(skillSourceParts.profile); - } - var skillList = drawer.querySelector('[data-page-ai-skill-list]'); - if (skillList instanceof HTMLElement) { - var skills = pageAiFilteredSkillEntries(); - if (!skills.length) { - skillList.innerHTML = '
    没有匹配的能力。
    '; - } else { - var sourceParts = pageAiSkillSourceParts(pageAiCurrentSkillSource()); - var group = sourceParts.group; - var headingExtra = group === 'hermes' ? ' · ' + sourceParts.profile : ''; - var collapsed = pageAiSkillGroupCollapsed(pageAiCurrentSkillSource()); - skillList.innerHTML = '' + - '
    ' + - '' + - (collapsed ? '' : skills.map(function(skill) { - var toolCount = Number(skill.toolCount || pageAiNormalizeArray(skill.tools).length || 0); - var disabledToolCount = Number(skill.disabledToolCount || 0); - var capabilityMeta = []; - if (toolCount > 0) capabilityMeta.push(String(toolCount) + ' 工具'); - if (disabledToolCount > 0) capabilityMeta.push(String(disabledToolCount) + ' 已关闭'); - if (pageAiNormalizeArray(skill.requiresContextRefs).length > 0) capabilityMeta.push('需要上下文'); - var categoryText = group === 'mnote' ? String(skill.categoryTitle || skill.category || '').trim() : ''; - var sourceMetaParts = []; - if (group === 'mnote') { - sourceMetaParts.push(categoryText || 'MNote'); - } else { - sourceMetaParts.push(pageAiSkillOriginLabel(skill)); - sourceMetaParts.push('只读查看'); - } - if (capabilityMeta.length) sourceMetaParts = sourceMetaParts.concat(capabilityMeta); - if (skill.readOnly && group === 'mnote') sourceMetaParts.push('只读'); - if (skill.modified) sourceMetaParts.push('modified'); - var sourceText = sourceMetaParts.filter(Boolean).join(' · '); - var description = String(skill.description || '').trim(); - var hasDescription = description && description !== '---' && description !== '无描述'; - var displayName = String(skill.title || skill.name || skill.id || '').trim(); - var disabled = skill.toggleable === false || skill.readOnly === true; - var switchControl = group === 'mnote' - ? '' - : '查看'; - return '' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(displayName) + '
    ' + - '
    ' + escapeHtml(sourceText) + '
    ' + - '
    ' + - (hasDescription ? '
    ' + escapeHtml(description) + '
    ' : '') + - '
    ' + - switchControl + - '
    '; - }).join('')) + - '
    '; - } - } - var toolsList = drawer.querySelector('[data-page-ai-tool-list]'); - if (toolsList instanceof HTMLElement) { - var tools = pageAiNormalizeArray(pageUiState.pageAiTools); - if (!tools.length) { - toolsList.innerHTML = '
    尚未读取到 mnote tool manifest。
    '; - } else { - toolsList.innerHTML = tools.map(function(tool) { - return '' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(tool.name) + '
    ' + - '
    ' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '
    ' + - (tool.unavailableReason ? '
    ' + escapeHtml(tool.unavailableReason) + '
    ' : '') + - '
    ' + - '' + - '
    '; - }).join(''); - } - } - var gatewayError = drawer.querySelector('[data-page-ai-gateway-error]'); - if (gatewayError instanceof HTMLElement) { - gatewayError.textContent = pageUiState.pageAiGatewayHealthError || ''; - gatewayError.hidden = !pageUiState.pageAiGatewayHealthError; - } - var gatewayStatusNode = drawer.querySelector('[data-page-ai-gateway-status]'); - var gatewayDetail = drawer.querySelector('[data-page-ai-gateway-detail]'); - var gatewayHealth = pageUiState.pageAiGatewayHealth; - if (gatewayStatusNode instanceof HTMLElement) { - if (!gatewayHealth) { - gatewayStatusNode.textContent = '未检查'; - } else { - var gateway = gatewayHealth.gateway || {}; - var profile = gatewayHealth.profile || {}; - gatewayStatusNode.textContent = gatewayHealth.ok ? '可用' : '需要设置'; - if (gateway.status) gatewayStatusNode.textContent += ' · ' + gateway.status; - if (profile.name) gatewayStatusNode.textContent += ' · ' + profile.name; - } - } - if (gatewayDetail instanceof HTMLElement) { - if (!gatewayHealth) { - gatewayDetail.innerHTML = '
    打开 Hermes 设置后会检查 gateway 与当前 profile。
    '; - } else { - var gatewayInfo = gatewayHealth.gateway || {}; - var profileInfo = gatewayHealth.profile || {}; - var suggestionText = pageAiNormalizeArray(gatewayHealth.suggestions).join(';'); - gatewayDetail.innerHTML = '' + - '
    ' + - '
    ' + escapeHtml(profileInfo.name || activeProfile) + '
    ' + - '
    model.default: ' + escapeHtml(profileInfo.modelDefault || '未设置') + '
    ' + - '
    provider: ' + escapeHtml(profileInfo.provider || '未设置') + ' · API key: ' + escapeHtml(profileInfo.apiKeyConfigured ? '已配置' : '未检测到') + '
    ' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(gatewayInfo.upstream || '未配置 upstream') + '
    ' + - '
    gateway: ' + escapeHtml(gatewayInfo.status || 'unknown') + (gatewayInfo.httpStatus ? ' · HTTP ' + escapeHtml(gatewayInfo.httpStatus) : '') + '
    ' + - (suggestionText ? '
    ' + escapeHtml(suggestionText) + '
    ' : '') + - '
    '; - } - } - var toolError = drawer.querySelector('[data-page-ai-tool-error]'); - if (toolError instanceof HTMLElement) { - toolError.textContent = pageUiState.pageAiToolsError || ''; - toolError.hidden = !pageUiState.pageAiToolsError; - } - var lastTool = drawer.querySelector('[data-page-ai-last-tool]'); - if (lastTool instanceof HTMLElement) { - var call = pageUiState.pageAiLastToolCall; - lastTool.textContent = call - ? [call.event, call.name, call.runId, call.traceId || call.auditId].filter(Boolean).join(' · ') - : '暂无 tool call'; - } - var reasonixPanel = drawer.querySelector('[data-page-ai-reasonix-panel]'); - if (reasonixPanel instanceof HTMLElement) { - reasonixPanel.innerHTML = '' + - pageAiRenderAgentDescriptorCard('reasonix', 'Reasonix') + - '
    ' + - '
    ' + - '
    Reasonix 专属设置
    ACP runtime / memory
    ' + - '' + - '
    ' + - '
    ' + escapeHtml(pageAiReasonixMemoryEnabled() ? '当前用户已开启 Reasonix global/project memory。' : '默认关闭 Reasonix memory,避免跨用户偏好混入。') + '
    ' + - '
    '; - } - var chatOnlyPanel = drawer.querySelector('[data-page-ai-chat-only-panel]'); - if (chatOnlyPanel instanceof HTMLElement) { - chatOnlyPanel.innerHTML = pageAiRenderAgentDescriptorCard('chat_only', 'API Chat / Chat-only profile') + - '
    API Chat / Chat-only profile
    默认不申请文件写权限,归属 MNote 设置。
    '; - } - } - - function humanizePageAiResponse(rawText, promptText) { - var providerLabel = pageAiProviderLabel(pageUiState.pageAiProvider); - var text = String(rawText || '').trim(); - if (!text) { - return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”,但这次没有返回可读内容。'; - } - if (text.startsWith('{')) { - try { - var payload = JSON.parse(text); - var operation = payload && payload.operation ? payload.operation : {}; - var normalized = operation && operation.normalized_input ? operation.normalized_input : {}; - var args = normalized && normalized.args ? normalized.args : {}; - var documentId = searchText(args.documentId || args.pageId || currentDocumentId()); - var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get'); - return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 Hermes 调用 mnote plugin 工具,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。'; - } catch (_) { - return providerLabel + ' 已返回结果,但当前结果是结构化文本,已为你保留原始内容。'; - } - } - return text; - } - - function ensurePageAiDrawer() { - var existing = documentRef.querySelector('[data-testid="wolai-page-ai-drawer"]'); - if (existing instanceof HTMLElement) return existing; - var drawer = documentRef.createElement('aside'); - drawer.className = 'wolai-page-ai-drawer'; - drawer.setAttribute('data-testid', 'wolai-page-ai-drawer'); - drawer.setAttribute('data-mnote-surface', 'page-ai'); - drawer.hidden = true; - drawer.innerHTML = '' + - '' + - ''; - documentRef.body.appendChild(drawer); - return drawer; - } - - function renderPageAiSuggestions() { - var drawer = ensurePageAiDrawer(); - var list = drawer.querySelector('[data-page-ai-panel="chat"] [data-page-ai-suggestion-list]'); - if (!(list instanceof HTMLElement)) return; - var container = list.closest('.wolai-page-ai-suggestions'); - if (container instanceof HTMLElement) { - container.hidden = pageUiState.pageAiPage !== 'chat' || pageUiState.pageAiMessages.length > 0; - } - var suggestions = pageAiSuggestions(); - var offset = pageUiState.pageAiSuggestionIndex % suggestions.length; - var ordered = suggestions.slice(offset).concat(suggestions.slice(0, offset)).slice(0, 3); - list.innerHTML = ordered.map(function(text) { - return ''; - }).join(''); - } - - function pageAiConversationShouldStickToBottom(conversation) { - var distance = conversation.scrollHeight - conversation.scrollTop - conversation.clientHeight; - return distance <= 48; - } - - function pageAiCaptureOpenToolDetails(conversation) { - var openToolDetails = {}; - conversation.querySelectorAll('[data-page-ai-collapse-card], [data-page-ai-tool-card]').forEach(function(card) { - if (!(card instanceof HTMLElement)) return; - var key = String(card.getAttribute('data-page-ai-collapse-id') || card.getAttribute('data-page-ai-tool-group-id') || card.getAttribute('data-page-ai-tool-call-id') || '').trim(); - var details = card.querySelector('[data-page-ai-collapse-details], .wolai-page-ai-tool-details'); - if (key && details instanceof HTMLDetailsElement && details.open) { - openToolDetails[key] = true; - } - }); - return openToolDetails; - } - - function pageAiRestoreOpenToolDetails(conversation, openToolDetails) { - if (!openToolDetails || typeof openToolDetails !== 'object') return; - conversation.querySelectorAll('[data-page-ai-collapse-card], [data-page-ai-tool-card]').forEach(function(card) { - if (!(card instanceof HTMLElement)) return; - var key = String(card.getAttribute('data-page-ai-collapse-id') || card.getAttribute('data-page-ai-tool-group-id') || card.getAttribute('data-page-ai-tool-call-id') || '').trim(); - if (!key || openToolDetails[key] !== true) return; - var details = card.querySelector('[data-page-ai-collapse-details], .wolai-page-ai-tool-details'); - if (details instanceof HTMLDetailsElement) details.open = true; - }); - } - - function pageAiRenderToolDetailRows(item) { - var locationRows = Array.isArray(item.locations) && item.locations.length - ? '
    位置:' + item.locations.map(function(loc) { - return '' + - '' + escapeHtml(loc) + '' + - '' + - ''; - }).join('') + '
    ' - : ''; - return [ - locationRows, - item.argsSummary ? '
    参数 ' + escapeHtml(item.argsSummary) + '
    ' : '', - item.resultSummary ? '
    结果 ' + escapeHtml(item.resultSummary) + '
    ' : '', - item.changedFiles && item.changedFiles.length ? '
    ' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '
    ' : '', - (item.traceId || item.auditId) ? '
    ' + escapeHtml([item.traceId, item.auditId].filter(Boolean).join(' · ')) + '
    ' : '' - ].filter(Boolean).join(''); - } - - function pageAiToolStatusLabel(status) { - if (status === 'completed') return '完成'; - if (status === 'failed') return '失败'; - return '运行中'; - } - - function pageAiToolGroupStatus(items) { - if (items.some(function(item) { return item.status === 'failed'; })) return 'failed'; - if (items.some(function(item) { return item.status !== 'completed'; })) return 'running'; - return 'completed'; - } - - function pageAiRenderToolGroup(items, groupIndex) { - var tools = pageAiNormalizeArray(items); - if (!tools.length) return ''; - var status = pageAiToolGroupStatus(tools); - var completedCount = tools.filter(function(item) { return item.status === 'completed'; }).length; - var failedCount = tools.filter(function(item) { return item.status === 'failed'; }).length; - var runningCount = tools.length - completedCount - failedCount; - var countParts = [String(tools.length) + ' 个']; - if (completedCount) countParts.push('完成 ' + String(completedCount)); - if (failedCount) countParts.push('失败 ' + String(failedCount)); - if (runningCount) countParts.push('运行中 ' + String(runningCount)); - var firstToolId = String(tools[0] && tools[0].toolCallId || '').trim(); - var groupId = 'tool-group-' + String(groupIndex) + '-' + (firstToolId || String(tools.length)); - var rows = tools.map(function(item) { - var statusLabel = pageAiToolStatusLabel(item.status); - var meta = [statusLabel, item.toolKind, item.toolCallId].filter(Boolean).join(' · '); - return '' + - '
    ' + - '
    ' + - '' + escapeHtml(item.toolName || item.content || 'tool') + '' + - '' + escapeHtml(meta) + '' + - '
    ' + - (pageAiRenderToolDetailRows(item) || '
    暂无参数或结果详情。
    ') + - '
    '; - }).join(''); - return '' + - '
    ' + - '
    工具
    ' + - '
    ' + - '
    ' + - '' + - '调用工具' + - '' + escapeHtml(countParts.join(' · ')) + '' + - '' + - '
    ' + rows + '
    ' + - '
    ' + - '
    ' + - '
    '; - } - - function pageAiRenderThoughtGroup(items, groupIndex) { - var thoughts = pageAiNormalizeArray(items).filter(function(item) { - return item && item.kind === 'thought' && String(item.content || '').trim(); - }); - if (!thoughts.length) return ''; - var firstText = String(thoughts[0] && thoughts[0].content || '').slice(0, 24).replace(/\s+/g, ' ').trim(); - var groupId = 'thought-group-' + String(groupIndex) + '-' + String(thoughts.length) + '-' + (firstText || 'delta'); - var content = thoughts.map(function(item) { - return String(item.content || '').trim(); - }).filter(Boolean).join('\n\n'); - var countLabel = thoughts.length > 1 ? String(thoughts.length) + ' 段' : '1 段'; - return '' + - '
    ' + - '
    AI
    ' + - '
    ' + - '
    ' + - '' + - '思考过程' + - '' + escapeHtml(countLabel) + '' + - '' + - '
    ' + escapeHtml(content) + '
    ' + - '
    ' + - '
    ' + - '
    '; - } - - function pageAiLooksLikeReasonixPlanMessage(item) { - if (!item || item.role !== 'assistant') return false; - var text = String(item.content || '').trim(); - if (!text || text.length < 40) return false; - if (/ACP connection closed|AI 当前请求失败|run failed/i.test(text)) return false; - var hasPlanWord = /计划|步骤|执行|修改|写入|新建|验证|plan/i.test(text); - var awaitsApproval = /请批准|待批准|批准后|确认后|审批|批准,|开始执行|approve/i.test(text); - var hasStepShape = /(^|\n)\s*(?:\d+[\.\、)]|[-*]\s+|\|\s*\d+\s*\|)/.test(text); - return hasPlanWord && awaitsApproval && hasStepShape; - } - - function pageAiExtractReasonixPlanSteps(text) { - var seen = {}; - var steps = []; - String(text || '').replace(/\r\n/g, '\n').split('\n').forEach(function(line) { - var trimmed = line.trim(); - if (!trimmed) return; - var step = ''; - var tableCells = trimmed.indexOf('|') >= 0 - ? trimmed.split('|').map(function(cell) { return cell.trim(); }).filter(Boolean) - : []; - if (tableCells.length >= 2 && /^\d+$/.test(tableCells[0])) { - step = tableCells.slice(1).join(' · '); - } else { - var numbered = trimmed.match(/^\s*\d+[\.\、)]\s+(.+)$/); - var bullet = trimmed.match(/^\s*[-*]\s+(?:\[[ xX~>|-]\]\s*)?(.+)$/); - step = numbered ? numbered[1] : (bullet ? bullet[1] : ''); - } - step = String(step || '').replace(/\s+/g, ' ').trim(); - if (!step || step.length < 3 || /^[-:|]+$/.test(step)) return; - if (seen[step]) return; - seen[step] = true; - var lower = step.toLowerCase(); - var status = /✅|已完成|done|completed/.test(step) ? 'completed' - : (/当前|进行中|执行中|待批准|in progress|running/.test(lower) ? 'inProgress' : 'pending'); - steps.push({ text: step, status: status }); - }); - return steps.slice(0, 8); - } - - function pageAiPlanStepIcon(status) { - if (status === 'completed') return '✓'; - if (status === 'inProgress') return '…'; - return String.fromCharCode(8226); - } - - function pageAiRenderReasonixPlanCard(item) { - var text = String(item.content || '').trim(); - var steps = pageAiExtractReasonixPlanSteps(text); - var stepHtml = steps.length ? ( - '
      ' + steps.map(function(step) { - return '' + - '
    1. ' + - '' + escapeHtml(pageAiPlanStepIcon(step.status)) + '' + - '' + escapeHtml(step.text || '') + '' + - '
    2. '; - }).join('') + '
    ' - ) : ''; - return '' + - '
    ' + - '
    ' + - '
    ' + - 'Reasonix' + - '执行计划' + - '
    ' + - '待批准' + - '
    ' + - '
    ' + renderPageAiMarkdown(text) + '
    ' + - stepHtml + - '
    ' + - '' + - '' + - '' + - '
    ' + - '
    '; - } - - function pageAiRenderPermissionDetails(item) { - var details = item && item.permissionDetails && typeof item.permissionDetails === 'object' - ? item.permissionDetails - : {}; - var rows = Array.isArray(details.rows) ? details.rows : []; - if (!rows.length) { - rows = [ - { label: '请求', value: item.argsSummary || item.content || '' } - ]; - } - return '
    ' + rows.slice(0, 6).map(function(row) { - return '
    ' + escapeHtml(row.label || '') + '' + escapeHtml(row.value || '') + '
    '; - }).join('') + '
    '; - } - - function pageAiRenderNonToolMessage(item) { - var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI'); - if (item.kind === 'status') { - return '' + - '
    ' + - '
    AI
    ' + - '
    ' + escapeHtml(item.content || '') + '
    ' + - '
    '; - } - if (item.kind === 'thought') { - return '' + - '
    ' + - '
    AI
    ' + - '
    ' + - '
    ' + - '思考过程1 段' + - '
    ' + escapeHtml(item.content || '') + '
    ' + - '
    ' + - '
    ' + - '
    '; - } - if (item.kind === 'permission') { - var permissionOptions = Array.isArray(item.options) ? item.options : []; - var permissionActions = ''; - if (!item.resolved) { - if (permissionOptions.length) { - permissionActions = '
    ' + permissionOptions.map(function(option) { - var optionText = String(option.optionId || '') + ' ' + String(option.kind || '') + ' ' + String(option.name || ''); - var denyLike = /reject|deny|cancel|stop|no/.test(optionText.toLowerCase()); - return ''; - }).join('') + '
    '; - } else { - permissionActions = '' + - '
    ' + - '' + - '' + - '
    '; - } - } - return '' + - '
    ' + - '
    ' + - '
    ' + - '权限' + - '' + escapeHtml(item.toolName || 'session/request_permission') + '' + - '
    ' + - '' + escapeHtml(item.resolved ? (item.decision === 'allow' || item.decision === 'allowed' ? '已允许' : '已处理') : '待确认') + '' + - '
    ' + - '
    ' + - pageAiRenderPermissionDetails(item) + - permissionActions + - '
    ' + - '
    '; - } - if (item.kind === 'plan') { - var planEntries = Array.isArray(item.entries) ? item.entries : []; - var listHtml = planEntries.map(function(entry) { - return '
  • ' + escapeHtml(String(entry || '')) + '
  • '; - }).join(''); - return '' + - '
    ' + - '
    ' + - '执行计划 · ' + planEntries.length + ' 步' + - '
    ' + - '
      ' + listHtml + '
    ' + - '
    ' + - '
    ' + - '
    '; - } - if (pageAiLooksLikeReasonixPlanMessage(item)) return pageAiRenderReasonixPlanCard(item); - var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : ''; - var detailButton = item.boardRunId - ? '
    ' + (item.retryPrompt ? '' : '') + '
    ' - : ''; - return '' + - '
    ' + - '
    ' + escapeHtml(roleLabel) + '
    ' + - '
    ' + (item.role === 'assistant' ? renderPageAiMarkdown(item.content || '') : escapeHtml(item.content || '')) + '
    ' + - detailButton + - '
    '; - } - - function pageAiConversationRenderHtml(messages) { - var turns = []; - var current = null; - function ensureTurn() { - if (!current) current = { user: null, tools: [], status: [], assistants: [], others: [] }; - return current; - } - function flushTurn() { - if (!current) return; - turns.push(current); - current = null; - } - pageAiNormalizeArray(messages).forEach(function(item) { - if (item && item.role === 'user') { - flushTurn(); - current = { user: item, tools: [], status: [], assistants: [], others: [] }; - return; - } - var turn = ensureTurn(); - if (item && item.role === 'tool' && item.kind !== 'permission') { - turn.tools.push(item); - } else if (item && (item.kind === 'thought' || item.kind === 'plan' || item.kind === 'permission')) { - turn.status.push(item); - } else if (item && item.role === 'assistant') { - turn.assistants.push(item); - } else { - turn.others.push(item); - } - }); - flushTurn(); - var toolGroupIndex = 0; - var thoughtGroupIndex = 0; - return turns.map(function(turn) { - var html = []; - if (turn.user) html.push(pageAiRenderNonToolMessage(turn.user)); - if (turn.tools.length) html.push(pageAiRenderToolGroup(turn.tools, toolGroupIndex++)); - var thoughts = turn.status.filter(function(item) { return item && item.kind === 'thought'; }); - var otherStatus = turn.status.filter(function(item) { return !(item && item.kind === 'thought'); }); - if (thoughts.length) html.push(pageAiRenderThoughtGroup(thoughts, thoughtGroupIndex++)); - html = html.concat(otherStatus.map(pageAiRenderNonToolMessage)); - html = html.concat(turn.assistants.map(pageAiRenderNonToolMessage)); - html = html.concat(turn.others.map(pageAiRenderNonToolMessage)); - return html.join(''); - }).join(''); - } - - function pageAiHistoryTextKey(value) { - return String(value || '') - .replace(/\s+/g, ' ') - .replace(/[.…]+$/g, '') - .trim(); - } - - function pageAiHistoryPreviewIsDuplicate(title, preview) { - var titleKey = pageAiHistoryTextKey(title); - var previewKey = pageAiHistoryTextKey(preview); - if (!titleKey || !previewKey) return false; - return titleKey === previewKey || previewKey.indexOf(titleKey) === 0 || titleKey.indexOf(previewKey) === 0; - } - - function pageAiBoardRunDetailHtml(runId) { - var current = typeof pageAiCurrentSession === 'function' ? pageAiCurrentSession() : null; - var detail = pageUiState.pageAiBoardRunDetails && pageUiState.pageAiBoardRunDetails[runId] - || current && current.boardRuns && current.boardRuns[runId] - || null; - if (!detail) return '
    暂无 run detail。
    '; - var changedFiles = pageAiNormalizeArray(detail.changedFiles).map(function(file) { - return '
  • ' + escapeHtml(String(file.path || file || '')) + (file.status ? ' · ' + escapeHtml(file.status) : '') + '
  • '; - }).join(''); - var verification = pageAiNormalizeArray(detail.verification).map(function(item) { - return '
  • ' + escapeHtml([item.command, item.status, item.output].filter(Boolean).join(' · ')) + '
  • '; - }).join(''); - var timeline = pageAiNormalizeArray(detail.timeline).map(function(event) { - return '
  • ' + escapeHtml([event.type, event.message, event.timestamp].filter(Boolean).join(' · ')) + '
  • '; - }).join(''); - return '' + - '
    ' + - '
    ' + - '
    Run detail' + escapeHtml(detail.runId || runId) + '
    ' + - '' + escapeHtml(detail.status || '') + '' + - '
    ' + - '
    ' + - '
    Worker' + escapeHtml(detail.workerName || detail.workerPresetId || '') + '
    ' + - '
    Model' + escapeHtml(detail.modelLabel || detail.modelOverride || '') + '
    ' + - '
    Workflow' + escapeHtml(detail.workflowName || detail.workflowId || '') + '
    ' + - '
    ' + - (changedFiles ? '
    changed files' + String(pageAiNormalizeArray(detail.changedFiles).length) + '
      ' + changedFiles + '
    ' : '') + - (verification ? '
    verification' + String(pageAiNormalizeArray(detail.verification).length) + '
      ' + verification + '
    ' : '') + - (timeline ? '
    timeline' + String(pageAiNormalizeArray(detail.timeline).length) + '
      ' + timeline + '
    ' : '') + - '
    '; - } - - function renderPageAiConversation() { - var drawer = ensurePageAiDrawer(); - renderPageAiSuggestions(); - var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]'); - if (!(conversation instanceof HTMLElement)) return; - if (pageUiState.pageAiPage === 'history') { - var sourceRows = pageUiState.pageAiSessionSearchResults.length - ? pageUiState.pageAiSessionSearchResults - : pageUiState.pageAiSessions; - var historyRows = pageAiFilteredHistoryRows(sourceRows); - if (!historyRows.length) { - conversation.innerHTML = '
    没有匹配的历史会话。
    '; - return; - } - conversation.innerHTML = historyRows.map(function(session) { - var title = String(session.title || '新会话').trim() || '新会话'; - var preview = pageAiSessionPreviewText(session); - var previewHtml = pageAiHistoryPreviewIsDuplicate(title, preview) ? '' : '' + escapeHtml(preview) + ''; - var active = session.id === pageUiState.pageAiActiveSessionId; - var selected = pageAiSessionSelected(session.id); - var usage = pageAiUsageSummary(session.usage); - var agentLabel = pageAiSessionAgentLabel(session); - var statusValue = pageAiSessionStatusFilterValue(session); - var meta = [agentLabel, pageAiSessionStorageLabel(session), session.runtimeMode, session.permissionLevel, session.shareId, pageAiSessionStatusLabel(statusValue), usage].filter(Boolean).join(' · '); - return '' + - '
    ' + - '' + - '' + - '
    ' + - '' + - '' + - '' + - '' + - '
    ' + - '
    '; - }).join(''); - return; - } - if (!pageUiState.pageAiMessages.length) { - conversation.innerHTML = '
    围绕当前页面提问,我会优先使用当前页内容、页面结构和已保存设置。
    '; - return; - } - var shouldStickToBottom = pageAiConversationShouldStickToBottom(conversation); - var previousScrollTop = conversation.scrollTop; - var openToolDetails = pageAiCaptureOpenToolDetails(conversation); - conversation.innerHTML = pageAiConversationRenderHtml(pageUiState.pageAiMessages); - if (pageUiState.pageAiBoardActiveDetailRunId) { - conversation.insertAdjacentHTML('beforeend', pageAiBoardRunDetailHtml(pageUiState.pageAiBoardActiveDetailRunId)); - } - pageAiRestoreOpenToolDetails(conversation, openToolDetails); - if (shouldStickToBottom) { - conversation.scrollTop = conversation.scrollHeight; - } else { - var maxScrollTop = Math.max(0, conversation.scrollHeight - conversation.clientHeight); - conversation.scrollTop = Math.min(previousScrollTop, maxScrollTop); - } - } - - return { - ensurePageAiDrawer, - humanizePageAiResponse, - pageAiContextButtonSummary, - pageAiContextRefDetail, - pageAiContextRefDisabled, - pageAiContextRefLabel, - pageAiCurrentAgentSelectionLabel, - pageAiCurrentModelLabel, - pageAiFilteredSkillEntries, - pageAiFormatChangedFiles, - pageAiMemoryFileLabel, - pageAiRenderAgentProfileOption, - pageAiRunStatusLabel, - pageAiSuggestions, - pageAiTargetDetail, - pageAiTargetLabel, - pageAiContextScopeLabel, - renderPageAiControls, - renderPageAiConversation, - renderPageAiProviderButtons, - renderPageAiSuggestions, - }; -} diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js index 53433c19..45ee9eb4 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -1,5392 +1,47 @@ -import { createSidebarPageAiMarkdownRuntime } from './sidebar-page-ai-markdown-runtime.js'; -import { createSidebarPageAiPermissionRuntime } from './sidebar-page-ai-permission-runtime.js'; -import { createSidebarPageAiProfileRuntime } from './sidebar-page-ai-profile-runtime.js'; -import { createSidebarPageAiRenderRuntime } from './sidebar-page-ai-render-runtime.js'; -import { createSidebarPageAiSessionRuntime } from './sidebar-page-ai-session-runtime.js'; -import { createSidebarPageAiSkillRuntime } from './sidebar-page-ai-skill-runtime.js'; -import { createSidebarPageAiTargetRuntime } from './sidebar-page-ai-target-runtime.js'; +// Page AI product facade — Pi Lab only. +// Hermes / OpenCode / ACP drawer 已物理删除;本模块仅保留 tree 需要的入口桥接。 export function createSidebarPageAiRuntime(context) { - const { - buildLocalFileOpenUrl, - currentDocumentId, - currentPageAggregate, - currentPageOptions, - currentRootUri, - currentSourceKind, - escapeHtml, - cssEscape, - openLocalResourceInActiveTab, - pageUiState, - resolveWorkspaceId, - searchText, - } = context; - var PAGE_AI_SESSION_STORAGE_VERSION = 4; - // Page AI 请求合同:agentId 只负责路由;contextRefs 是用户勾选的上下文地址; - // allowedRoots 只展示 SQLite directory_grants 解析结果,服务端仍会按当前用户重算; - // runTargetSnapshot 必须来自 OpenEditorsSnapshot,避免运行中 UI 切换导致目标漂移。 - var PAGE_AI_AGENT_FALLBACK_REGISTRY = [ - { id: 'hermes', label: 'Hermes', acpRuntime: 'hermes', canWriteFiles: true }, - { id: 'reasonix', label: 'Reasonix', acpRuntime: 'reasonix', canWriteFiles: true }, - { id: 'chat_only', label: 'Chat-only', acpRuntime: 'hermes', canWriteFiles: false } - ]; - var PAGE_AI_DESCRIPTOR_SCHEMA = 'mnote.ai_agent_descriptor.v1'; - var PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY = [ - { profileId: 'shared_deepseek_chat', baseProfile: 'deepseek-chat', label: 'DeepSeek' }, - { profileId: 'shared_doubao_chat', baseProfile: 'doubao-chat', label: '豆包' }, - { profileId: 'shared_gemini_chat', baseProfile: 'gemini-chat', label: 'Gemini' }, - { profileId: 'shared_api_deepseek_flash_chat', baseProfile: 'api-deepseek-flash-chat', label: 'DeepSeek Flash', providerKind: 'api-chat' }, - { profileId: 'shared_api_deepseek_pro_chat', baseProfile: 'api-deepseek-pro-chat', label: 'DeepSeek Pro', providerKind: 'api-chat' }, - { profileId: 'shared_api_gpt_chat', baseProfile: 'api-gpt-chat', label: 'GPT', providerKind: 'api-chat' }, - { profileId: 'shared_api_kimi_chat', baseProfile: 'api-kimi-chat', label: 'Kimi', providerKind: 'api-chat' }, - { profileId: 'shared_api_gemini_chat', baseProfile: 'api-gemini-chat', label: 'Gemini API', providerKind: 'api-chat' }, - { profileId: 'shared_api_grok_chat', baseProfile: 'api-grok-chat', label: 'Grok API', providerKind: 'api-chat' } - ]; - var PAGE_AI_CONTEXT_REF_REGISTRY = [ - { id: 'current_page', label: '当前页' }, - { id: 'selection', label: '选区' }, - { id: 'active_editor', label: '打开资源' }, - { id: 'file', label: '文件' }, - { id: 'folder', label: '文件夹' }, - { id: 'changed_files', label: '最近修改' } - ]; - var pageAiDelegatesInstalled = false; - var PAGE_AI_DRAWER_WIDTH_STORAGE_KEY = 'mnote.page_ai.drawer_width'; - var PAGE_AI_DRAWER_DEFAULT_WIDTH = 440; - var PAGE_AI_DRAWER_MIN_WIDTH = 340; - var PAGE_AI_DRAWER_MAX_WIDTH = 760; - var PAGE_AI_OPENCODE_MODEL = { providerID: 'opencodego', modelID: 'deepseek-v4-flash' }; - - function clonePageAiDefaultValue(value) { - if (Array.isArray(value)) return value.slice(); - if (value && typeof value === 'object') return Object.assign({}, value); - return value; - } - - function pageAiClampDrawerWidth(width) { - var viewportMax = Math.max(PAGE_AI_DRAWER_MIN_WIDTH, Math.min(PAGE_AI_DRAWER_MAX_WIDTH, window.innerWidth - 24)); - var numeric = Number(width); - if (!Number.isFinite(numeric) || numeric <= 0) numeric = PAGE_AI_DRAWER_DEFAULT_WIDTH; - return Math.max(PAGE_AI_DRAWER_MIN_WIDTH, Math.min(viewportMax, Math.round(numeric))); - } - - function pageAiStoredDrawerWidth() { - try { - return pageAiClampDrawerWidth(window.localStorage ? window.localStorage.getItem(PAGE_AI_DRAWER_WIDTH_STORAGE_KEY) : PAGE_AI_DRAWER_DEFAULT_WIDTH); - } catch (_error) { - return pageAiClampDrawerWidth(PAGE_AI_DRAWER_DEFAULT_WIDTH); - } - } - - function pageAiApplyDrawerWidth(drawer, width) { - var target = drawer instanceof HTMLElement ? drawer : document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - if (!(target instanceof HTMLElement)) return; - var next = pageAiClampDrawerWidth(width == null ? pageAiStoredDrawerWidth() : width); - target.style.setProperty('--mnote-page-ai-width', next + 'px'); - } - - function handlePageAiPointerDown(event, helpers) { - var closestAction = helpers && helpers.closestAction; - if (typeof closestAction !== 'function') return false; - var handle = closestAction(event.target, '[data-page-ai-resize-handle]'); - if (!(handle instanceof HTMLElement)) return false; - var drawer = handle.closest('[data-testid="wolai-page-ai-drawer"]'); - var panel = drawer instanceof HTMLElement ? drawer.querySelector('.wolai-page-ai-panel') : null; - if (!(drawer instanceof HTMLElement) || !(panel instanceof HTMLElement)) return false; - event.preventDefault(); - var pointerId = event.pointerId; - var startX = Number(event.clientX || 0); - var startWidth = panel.getBoundingClientRect().width || pageAiStoredDrawerWidth(); - drawer.setAttribute('data-page-ai-resizing', 'true'); - document.documentElement.setAttribute('data-mnote-page-ai-resizing', 'true'); - try { - handle.setPointerCapture(pointerId); - } catch (_error) {} - function move(nextEvent) { - var nextWidth = startWidth + (startX - Number(nextEvent.clientX || 0)); - pageAiApplyDrawerWidth(drawer, nextWidth); - } - function finish(nextEvent) { - move(nextEvent); - var value = drawer.style.getPropertyValue('--mnote-page-ai-width').replace('px', '').trim(); - try { - if (window.localStorage) window.localStorage.setItem(PAGE_AI_DRAWER_WIDTH_STORAGE_KEY, String(pageAiClampDrawerWidth(value))); - } catch (_error) {} - drawer.removeAttribute('data-page-ai-resizing'); - document.documentElement.removeAttribute('data-mnote-page-ai-resizing'); - window.removeEventListener('pointermove', move, true); - window.removeEventListener('pointerup', finish, true); - window.removeEventListener('pointercancel', finish, true); - try { - handle.releasePointerCapture(pointerId); - } catch (_error) {} - } - window.addEventListener('pointermove', move, true); - window.addEventListener('pointerup', finish, true); - window.addEventListener('pointercancel', finish, true); - return true; - } - - function ensurePageAiStateFacade(state) { - var defaults = { - pageAiOpen: false, - pageAiBusy: false, - pageAiMessages: [], - pageAiSuggestionIndex: 0, - pageAiProvider: 'hermes', - pageAiPage: 'chat', - pageAiRunStatus: 'idle', - pageAiCurrentRunId: '', - pageAiAcpRuntime: 'reasonix', - pageAiAcpRuntimes: [], - pageAiQueueLength: 0, - pageAiQueuedItems: [], - pageAiClientQueuedPrompts: [], - pageAiStoppedRunIds: {}, - pageAiAbortController: null, - pageAiContextScope: 'page', - pageAiContextPopoverOpen: false, - pageAiTargetPopoverOpen: false, - pageAiSelectedTargetId: '', - pageAiSelectedContextRefs: null, - pageAiCurrentRunTargetSnapshot: null, - pageAiAllowedRoots: [], - pageAiAllowedRootsError: '', - pageAiAgentId: 'reasonix', - pageAiAgentPopoverOpen: false, - pageAiAgentDescriptors: [], - pageAiAgentDescriptorError: '', - pageAiAgentDescriptorsLoaded: false, - pageAiTools: [], - pageAiToolsError: '', - pageAiGatewayHealth: null, - pageAiGatewayHealthError: '', - pageAiLastToolCall: null, - pageAiProfiles: [], - pageAiActiveProfileName: 'mnoteai', - pageAiProfileError: '', - pageAiProfileMemory: { memory: '', user: '', soul: '' }, - pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' }, - pageAiProfileMemoryError: '', - pageAiSkills: { categories: [], archived: [] }, - pageAiSkillCatalogs: { - mnote: { categories: [], archived: [] }, - reasonix: { categories: [], archived: [] }, - hermes: { categories: [], archived: [] } - }, - pageAiSkillPreferences: {}, - pageAiCollapsedSkillGroups: {}, - pageAiSkillQuery: '', - pageAiSkillError: '', - pageAiSkillLoadSeq: 0, - pageAiActiveSkillSource: 'mnote', - pageAiSessions: [], - pageAiActiveSessionId: '', - pageAiBoardWorkerId: 'mnote-page-ai-zcode', - pageAiBoardWorkflowId: 'builtin-mnote-page-ai-chat', - pageAiBoardModelOverride: 'zcode-default', - pageAiBoardRunDetails: {}, - pageAiBoardActiveDetailRunId: '', - pageAiOpencodeStatus: null, - pageAiOpencodeStatusError: '', - pageAiOpencodeChangedFiles: [], - pageAiOpencodeDiffError: '', - pageAiOpencodeSessionId: '', - pageAiOpencodeBaseUrl: 'http://127.0.0.1:4096', - pageAiOpencodeIframeUrl: '', - pageAiOpencodeBindingKey: '', - pageAiOpencodeBindingInFlight: false, - pageAiOpencodeEventSource: null, - pageAiOpencodeEventSessionId: '', - pageAiOpencodeEventTimer: 0, - pageAiSessionSearchQuery: '', - pageAiSessionSearchResults: [], - pageAiSessionSearchTimer: 0, - pageAiSessionAgentFilter: 'all', - pageAiSessionStatusFilter: 'all', - pageAiSelectedSessionIds: {}, - pageAiSessionError: '', - pageAiRuntimeStatus: null, - pageAiRuntimeStatusError: '', - pageAiPermissionRequests: [] - }; - Object.keys(defaults).forEach(function(key) { - if (typeof state[key] === 'undefined') { - state[key] = clonePageAiDefaultValue(defaults[key]); - } - }); - if (!state.pageAiSkillCatalogs || typeof state.pageAiSkillCatalogs !== 'object') { - state.pageAiSkillCatalogs = clonePageAiDefaultValue(defaults.pageAiSkillCatalogs); - } - if (!state.pageAiProfileMemory || typeof state.pageAiProfileMemory !== 'object') { - state.pageAiProfileMemory = clonePageAiDefaultValue(defaults.pageAiProfileMemory); - } - if (!state.pageAiProfileMemoryDrafts || typeof state.pageAiProfileMemoryDrafts !== 'object') { - state.pageAiProfileMemoryDrafts = clonePageAiDefaultValue(defaults.pageAiProfileMemoryDrafts); - } - if (!state.pageAiSkills || typeof state.pageAiSkills !== 'object') { - state.pageAiSkills = clonePageAiDefaultValue(defaults.pageAiSkills); - } - if (!Array.isArray(state.pageAiClientQueuedPrompts)) { - state.pageAiClientQueuedPrompts = []; - } - return state; - } - - ensurePageAiStateFacade(pageUiState); - - function closestAction(target, selector) { - var node = target && target.nodeType === Node.TEXT_NODE ? target.parentElement : target; - return node && typeof node.closest === 'function' ? node.closest(selector) : null; - } - - const pageAiMarkdown = createSidebarPageAiMarkdownRuntime({ escapeHtml }); - const textFromUnknown = (...args) => pageAiMarkdown.textFromUnknown(...args); - const renderPageAiMarkdown = (...args) => pageAiMarkdown.renderPageAiMarkdown(...args); - let pageAiRenderRuntime = null; - - function pageAiRenderApi() { - if (!pageAiRenderRuntime) throw new Error('page_ai_render_runtime_not_ready'); - return pageAiRenderRuntime; - } - - function ensurePageAiDrawer(...args) { return pageAiRenderApi().ensurePageAiDrawer(...args); } - function humanizePageAiResponse(...args) { return pageAiRenderApi().humanizePageAiResponse(...args); } - function pageAiContextButtonSummary(...args) { return pageAiRenderApi().pageAiContextButtonSummary(...args); } - function pageAiContextRefDetail(...args) { return pageAiRenderApi().pageAiContextRefDetail(...args); } - function pageAiContextRefDisabled(...args) { return pageAiRenderApi().pageAiContextRefDisabled(...args); } - function pageAiContextRefLabel(...args) { return pageAiRenderApi().pageAiContextRefLabel(...args); } - function pageAiContextScopeLabel(...args) { return pageAiRenderApi().pageAiContextScopeLabel(...args); } - function pageAiCurrentAgentSelectionLabel(...args) { return pageAiRenderApi().pageAiCurrentAgentSelectionLabel(...args); } - function pageAiCurrentModelLabel(...args) { return pageAiRenderApi().pageAiCurrentModelLabel(...args); } - function pageAiFilteredSkillEntries(...args) { return pageAiRenderApi().pageAiFilteredSkillEntries(...args); } - function pageAiFormatChangedFiles(...args) { return pageAiRenderApi().pageAiFormatChangedFiles(...args); } - function pageAiMemoryFileLabel(...args) { return pageAiRenderApi().pageAiMemoryFileLabel(...args); } - function pageAiRenderAgentProfileOption(...args) { return pageAiRenderApi().pageAiRenderAgentProfileOption(...args); } - function pageAiRunStatusLabel(...args) { return pageAiRenderApi().pageAiRunStatusLabel(...args); } - function pageAiSuggestions(...args) { return pageAiRenderApi().pageAiSuggestions(...args); } - function pageAiTargetDetail(...args) { return pageAiRenderApi().pageAiTargetDetail(...args); } - function pageAiTargetLabel(...args) { return pageAiRenderApi().pageAiTargetLabel(...args); } - function renderPageAiControls(...args) { return pageAiRenderApi().renderPageAiControls(...args); } - function renderPageAiConversation(...args) { return pageAiRenderApi().renderPageAiConversation(...args); } - function renderPageAiProviderButtons(...args) { return pageAiRenderApi().renderPageAiProviderButtons(...args); } - function renderPageAiSuggestions(...args) { return pageAiRenderApi().renderPageAiSuggestions(...args); } - - function readLocalEditorBlocks() { - var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror'); - if (!(editor instanceof HTMLElement)) return []; - return Array.from(editor.children).filter(function(node) { - return node instanceof HTMLElement; - }).map(function(node, index) { - var tag = String(node.tagName || '').toUpperCase(); - var headingMatch = tag.match(/^H([1-6])$/); - var type = headingMatch ? 'heading' : 'paragraph'; - var text = searchText(node.textContent || ''); - var id = searchText(node.getAttribute('data-id') || node.id || 'local-block-' + index); - var props = headingMatch ? { level: Number(headingMatch[1]) } : {}; - return { id: id, type: type, props: props, content: text }; - }).filter(function(block) { - return block.content || block.type === 'heading'; - }); - } - - function buildPageAiLocalSubtree(blocks, title) { - var documentId = currentDocumentId() || 'current-page'; - var rootNodeId = 'page:' + documentId; - var headingCounters = [0, 0, 0, 0, 0, 0]; - var headingStack = []; - var nodes = [{ - id: rootNodeId, - nodeId: rootNodeId, - nodeType: 'page', - blockId: null, - blockType: 'page', - title: title || '', - parentNodeId: null, - headingLevel: null - }]; - var outline = []; - var evidence = []; - blocks.forEach(function(block, index) { - var level = block.type === 'heading' ? Math.max(1, Math.min(6, Number(block.props && block.props.level || 1))) : null; - if (level != null) { - while (headingStack.length && headingStack[headingStack.length - 1].level >= level) headingStack.pop(); - } - var parentNodeId = headingStack.length ? headingStack[headingStack.length - 1].nodeId : rootNodeId; - var nodeId = 'local-node:' + String(block.id || index); - var titleText = block.content || (block.type === 'heading' ? '未命名标题' : ''); - nodes.push({ - id: nodeId, - nodeId: nodeId, - nodeType: 'block', - blockId: block.id, - blockType: block.type, - title: titleText, - parentNodeId: parentNodeId, - headingLevel: level - }); - if (level != null) { - headingCounters[level - 1] += 1; - for (var i = level; i < headingCounters.length; i += 1) headingCounters[i] = 0; - var numbering = headingCounters.slice(0, level).filter(function(count) { return count > 0; }).join('.'); - outline.push({ - id: 'local-outline:' + String(block.id || index), - nodeId: nodeId, - anchorBlockId: block.id, - level: level, - title: titleText, - numbering: numbering - }); - headingStack.push({ level: level, nodeId: nodeId }); - } - if (titleText) { - evidence.push({ - id: 'local-evidence:' + String(block.id || index), - nodeId: nodeId, - anchorBlockId: block.id, - text: titleText, - kind: block.type - }); - } - }); - return { - projectionId: 'local-editor-dom:' + documentId, - rootNode: { - id: rootNodeId, - documentId: documentId, - title: title || '', - nodeType: 'page' - }, - subtree: { - rootNodeId: rootNodeId, - nodes: nodes - }, - outline: outline, - evidence: evidence, - stats: { - nodeCount: nodes.length, - headingCount: outline.length, - evidenceCount: evidence.length - }, - source: 'local' - }; - } - - function currentPageAiContextSnapshot() { - var aggregate = currentPageAggregate(); - var body = aggregate.body || {}; - var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null; - return { - aggregate: aggregate, - body: body, - subtree: serverSubtree, - pageSubtreeSource: serverSubtree ? 'server' : 'none' - }; - } - - function pageAiNormalizeAgentDescriptor(descriptor) { - if (!descriptor || typeof descriptor !== 'object') return null; - var agentId = String(descriptor.agentId || descriptor.id || '').trim(); - if (!agentId) return null; - return Object.assign({}, descriptor, { - id: agentId, - agentId: agentId, - label: String(descriptor.displayName || descriptor.label || agentId).trim() || agentId, - acpRuntime: String(descriptor.acpRuntime || descriptor.acp_runtime || '').trim() || (agentId === 'reasonix' ? 'reasonix' : 'hermes'), - canWriteFiles: descriptor.canWriteFiles !== false, - capabilities: pageAiNormalizeArray(descriptor.capabilities), - fields: pageAiNormalizeArray(descriptor.fields), - tools: pageAiNormalizeArray(descriptor.tools), - capabilityPacks: pageAiNormalizeArray(descriptor.capabilityPacks), - schema: String(descriptor.schema || PAGE_AI_DESCRIPTOR_SCHEMA).trim() - }); - } - - function pageAiAgentRegistry() { - var descriptors = pageAiNormalizeArray(pageUiState.pageAiAgentDescriptors) - .map(pageAiNormalizeAgentDescriptor) - .filter(Boolean); - return descriptors.length ? descriptors : PAGE_AI_AGENT_FALLBACK_REGISTRY; - } - - function pageAiAgentDescriptor(agentId) { - var normalized = String(agentId || '').trim(); - if (!normalized) normalized = pageAiCurrentAgentId(); - return pageAiAgentRegistry().find(function(agent) { return agent.id === normalized || agent.agentId === normalized; }) || null; - } - - function pageAiNormalizeAgentId(agentId) { - var value = String(agentId || '').trim(); - return pageAiAgentRegistry().some(function(agent) { return agent.id === value; }) ? value : 'reasonix'; - } - - function pageAiAgentRecord(agentId) { - var normalized = pageAiNormalizeAgentId(agentId); - return pageAiAgentRegistry().find(function(agent) { return agent.id === normalized; }) || pageAiAgentRegistry()[1] || PAGE_AI_AGENT_FALLBACK_REGISTRY[1]; - } - - function pageAiCurrentAgentId() { - var explicit = String(pageUiState.pageAiAgentId || '').trim(); - if (explicit) return pageAiNormalizeAgentId(explicit); - var runtime = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim(); - if (runtime === 'hermes') return 'hermes'; - return 'reasonix'; - } - - const pageAiProfileRuntime = createSidebarPageAiProfileRuntime({ - chatOnlyProfileRegistry: PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY, - documentRef: document, - pageAiAgentRecord, - pageAiCurrentAgentId, - pageAiNormalizeAgentId, - pageUiState, - }); - const pageAiProviderLabel = (...args) => pageAiProfileRuntime.pageAiProviderLabel(...args); - const pageAiNormalizeArray = (...args) => pageAiProfileRuntime.pageAiNormalizeArray(...args); - const pageAiDefaultAcpRuntimes = (...args) => pageAiProfileRuntime.pageAiDefaultAcpRuntimes(...args); - const pageAiNormalizeAcpRuntimes = (...args) => pageAiProfileRuntime.pageAiNormalizeAcpRuntimes(...args); - const pageAiUnwrapUpstream = (...args) => pageAiProfileRuntime.pageAiUnwrapUpstream(...args); - const pageAiProfileValue = (...args) => pageAiProfileRuntime.pageAiProfileValue(...args); - const pageAiCurrentProfile = (...args) => pageAiProfileRuntime.pageAiCurrentProfile(...args); - const pageAiRunProfile = (...args) => pageAiProfileRuntime.pageAiRunProfile(...args); - const pageAiMnoteToolModel = (...args) => pageAiProfileRuntime.pageAiMnoteToolModel(...args); - const pageAiCurrentProfileRecord = (...args) => pageAiProfileRuntime.pageAiCurrentProfileRecord(...args); - const pageAiChatOnlyProfileSpec = (...args) => pageAiProfileRuntime.pageAiChatOnlyProfileSpec(...args); - const pageAiDefaultChatOnlyProfileSpec = (...args) => pageAiProfileRuntime.pageAiDefaultChatOnlyProfileSpec(...args); - const pageAiNormalizeChatOnlyProfileId = (...args) => pageAiProfileRuntime.pageAiNormalizeChatOnlyProfileId(...args); - const pageAiProfileDisplayLabel = (...args) => pageAiProfileRuntime.pageAiProfileDisplayLabel(...args); - const pageAiProfileRecordById = (...args) => pageAiProfileRuntime.pageAiProfileRecordById(...args); - const pageAiSessionAgentFilterValue = (...args) => pageAiProfileRuntime.pageAiSessionAgentFilterValue(...args); - const pageAiSessionAgentLabel = (...args) => pageAiProfileRuntime.pageAiSessionAgentLabel(...args); - const pageAiSessionPreviewText = (...args) => pageAiProfileRuntime.pageAiSessionPreviewText(...args); - const pageAiSessionAgentFilterOptions = (...args) => pageAiProfileRuntime.pageAiSessionAgentFilterOptions(...args); - const pageAiSessionStatusFilterValue = (...args) => pageAiProfileRuntime.pageAiSessionStatusFilterValue(...args); - const pageAiSessionStatusLabel = (...args) => pageAiProfileRuntime.pageAiSessionStatusLabel(...args); - const pageAiSessionStatusFilterOptions = (...args) => pageAiProfileRuntime.pageAiSessionStatusFilterOptions(...args); - const pageAiFilteredHistoryRows = (...args) => pageAiProfileRuntime.pageAiFilteredHistoryRows(...args); - const pageAiTimestamp = (...args) => pageAiProfileRuntime.pageAiTimestamp(...args); - const pageAiUsageSummary = (...args) => pageAiProfileRuntime.pageAiUsageSummary(...args); - const pageAiPermissionRuntime = createSidebarPageAiPermissionRuntime({ - documentRef: document, - pageAiPreviewValue, - pageUiState, - renderPageAiConversation, - }); - const pageAiPermissionMessage = (...args) => pageAiPermissionRuntime.pageAiPermissionMessage(...args); - const pageAiApplyPermissionEvent = (...args) => pageAiPermissionRuntime.pageAiApplyPermissionEvent(...args); - const pageAiResolvePermission = (...args) => pageAiPermissionRuntime.pageAiResolvePermission(...args); - const pageAiRestorePermissionRequests = (...args) => pageAiPermissionRuntime.pageAiRestorePermissionRequests(...args); - const pageAiHidePermissionDialog = (...args) => pageAiPermissionRuntime.pageAiHidePermissionDialog(...args); - const pageAiShowPermissionDialog = (...args) => pageAiPermissionRuntime.pageAiShowPermissionDialog(...args); - const pageAiSessionRuntime = createSidebarPageAiSessionRuntime({ - currentDocumentId, - currentRootUri, - currentSourceKind, - documentRef: document, - pageAiApplyRuntimeState, - pageAiCurrentAgentId, - pageAiCurrentProfile, - pageAiErrorMessage, - pageAiNormalizeAgentId, - pageAiNormalizeArray, - pageAiNormalizeChatOnlyProfileId, - pageAiPermissionMessage, - pageAiPreviewValue, - pageAiRunProfile, - pageAiSetActiveProfile, - pageAiTimestamp, - pageUiState, - renderPageAiControls, - renderPageAiConversation, - resolveWorkspaceId, - sessionStorageVersion: PAGE_AI_SESSION_STORAGE_VERSION, - windowRef: window, - }); - const pageAiStorageKey = (...args) => pageAiSessionRuntime.pageAiStorageKey(...args); - const pageAiBackendSessionQuery = (...args) => pageAiSessionRuntime.pageAiBackendSessionQuery(...args); - const pageAiNewSession = (...args) => pageAiSessionRuntime.pageAiNewSession(...args); - const pageAiNormalizeSessions = (...args) => pageAiSessionRuntime.pageAiNormalizeSessions(...args); - const pageAiNormalizeBackendSessionRow = (...args) => pageAiSessionRuntime.pageAiNormalizeBackendSessionRow(...args); - const pageAiMergeSessions = (...args) => pageAiSessionRuntime.pageAiMergeSessions(...args); - const pageAiSessionStorageLabel = (...args) => pageAiSessionRuntime.pageAiSessionStorageLabel(...args); - const pageAiLoadSessions = (...args) => pageAiSessionRuntime.pageAiLoadSessions(...args); - const pageAiLoadBackendSessions = (...args) => pageAiSessionRuntime.pageAiLoadBackendSessions(...args); - const pageAiMessageFromRuntimeEvent = (...args) => pageAiSessionRuntime.pageAiMessageFromRuntimeEvent(...args); - const pageAiApplyBackendSessionDetail = (...args) => pageAiSessionRuntime.pageAiApplyBackendSessionDetail(...args); - const pageAiLoadBackendSessionDetail = (...args) => pageAiSessionRuntime.pageAiLoadBackendSessionDetail(...args); - const pageAiSearchBackendSessions = (...args) => pageAiSessionRuntime.pageAiSearchBackendSessions(...args); - const pageAiPersistSessions = (...args) => pageAiSessionRuntime.pageAiPersistSessions(...args); - const pageAiEnsureHermesSession = (...args) => pageAiSessionRuntime.pageAiEnsureHermesSession(...args); - const pageAiRestoreHermesSession = (...args) => pageAiSessionRuntime.pageAiRestoreHermesSession(...args); - const pageAiCurrentSession = (...args) => pageAiSessionRuntime.pageAiCurrentSession(...args); - const pageAiSyncCurrentSessionMessages = (...args) => pageAiSessionRuntime.pageAiSyncCurrentSessionMessages(...args); - const pageAiSetActiveSession = (...args) => pageAiSessionRuntime.pageAiSetActiveSession(...args); - const pageAiStartNewSession = (...args) => pageAiSessionRuntime.pageAiStartNewSession(...args); - const pageAiRenameBackendSession = (...args) => pageAiSessionRuntime.pageAiRenameBackendSession(...args); - const pageAiExportBackendSession = (...args) => pageAiSessionRuntime.pageAiExportBackendSession(...args); - const pageAiDeleteBackendSession = (...args) => pageAiSessionRuntime.pageAiDeleteBackendSession(...args); - const pageAiDeleteSelectedBackendSessions = (...args) => pageAiSessionRuntime.pageAiDeleteSelectedBackendSessions(...args); - const pageAiResumeBackendSession = (...args) => pageAiSessionRuntime.pageAiResumeBackendSession(...args); - const pageAiSessionSelected = (...args) => pageAiSessionRuntime.pageAiSessionSelected(...args); - const pageAiToggleSessionSelection = (...args) => pageAiSessionRuntime.pageAiToggleSessionSelection(...args); - const pageAiClearSessionSelection = (...args) => pageAiSessionRuntime.pageAiClearSessionSelection(...args); - const pageAiSelectedSessionCount = (...args) => pageAiSessionRuntime.pageAiSelectedSessionCount(...args); - const pageAiCheckActiveRun = (...args) => pageAiSessionRuntime.pageAiCheckActiveRun(...args); - const pageAiSkillRuntime = createSidebarPageAiSkillRuntime({ - pageAiCurrentAgentId, - pageAiCurrentProfile, - pageAiLoadSkills, - pageAiNormalizeArray, - pageAiPersistAiPreference, - pageAiPersistRawAiPreference, - pageAiProfileValue, - pageUiState, - renderPageAiControls, - }); - const pageAiSkillSourceOptions = (...args) => pageAiSkillRuntime.pageAiSkillSourceOptions(...args); - const pageAiDefaultSkillSource = (...args) => pageAiSkillRuntime.pageAiDefaultSkillSource(...args); - const pageAiNormalizeSkillSource = (...args) => pageAiSkillRuntime.pageAiNormalizeSkillSource(...args); - const pageAiCurrentSkillSource = (...args) => pageAiSkillRuntime.pageAiCurrentSkillSource(...args); - const pageAiSetSkillSource = (...args) => pageAiSkillRuntime.pageAiSetSkillSource(...args); - const pageAiSkillSourceParts = (...args) => pageAiSkillRuntime.pageAiSkillSourceParts(...args); - const pageAiCurrentSkillSourceLabel = (...args) => pageAiSkillRuntime.pageAiCurrentSkillSourceLabel(...args); - const pageAiSkillOriginLabel = (...args) => pageAiSkillRuntime.pageAiSkillOriginLabel(...args); - const pageAiSkillPreferenceKey = (...args) => pageAiSkillRuntime.pageAiSkillPreferenceKey(...args); - const pageAiSkillPreferenceTable = (...args) => pageAiSkillRuntime.pageAiSkillPreferenceTable(...args); - const pageAiHermesHideBuiltinPreferenceKey = (...args) => pageAiSkillRuntime.pageAiHermesHideBuiltinPreferenceKey(...args); - const pageAiHideHermesBuiltinSkills = (...args) => pageAiSkillRuntime.pageAiHideHermesBuiltinSkills(...args); - const pageAiReasonixMemoryEnabled = (...args) => pageAiSkillRuntime.pageAiReasonixMemoryEnabled(...args); - const pageAiSetReasonixMemoryEnabled = (...args) => pageAiSkillRuntime.pageAiSetReasonixMemoryEnabled(...args); - const pageAiSetHideHermesBuiltinSkills = (...args) => pageAiSkillRuntime.pageAiSetHideHermesBuiltinSkills(...args); - const pageAiSkillIsBuiltin = (...args) => pageAiSkillRuntime.pageAiSkillIsBuiltin(...args); - const pageAiToggleableSkillEntries = (...args) => pageAiSkillRuntime.pageAiToggleableSkillEntries(...args); - const pageAiAllSkillEntries = (...args) => pageAiSkillRuntime.pageAiAllSkillEntries(...args); - const pageAiSkillGroupCollapsed = (...args) => pageAiSkillRuntime.pageAiSkillGroupCollapsed(...args); - const pageAiToggleSkillGroup = (...args) => pageAiSkillRuntime.pageAiToggleSkillGroup(...args); - const pageAiSetSkillPreference = (...args) => pageAiSkillRuntime.pageAiSetSkillPreference(...args); - const pageAiSkillEnabled = (...args) => pageAiSkillRuntime.pageAiSkillEnabled(...args); - - function pageAiEnsureContextRefState() { - if (!pageUiState.pageAiSelectedContextRefs || typeof pageUiState.pageAiSelectedContextRefs !== 'object') { - pageUiState.pageAiSelectedContextRefs = { - current_page: true, - selection: false, - active_editor: true, - file: false, - folder: false, - changed_files: false - }; - } - return pageUiState.pageAiSelectedContextRefs; - } - - const pageAiTargetRuntime = createSidebarPageAiTargetRuntime({ - currentDocumentId, - currentPageOptions, - currentRootUri, - currentSourceKind, - documentRef: document, - escapeHtml, - pageAiEnsureContextRefState, - pageAiNormalizeArray, - pageUiState, - resolveWorkspaceId, - searchText, - }); - const currentPageAiOpenEditorsSnapshot = (...args) => pageAiTargetRuntime.currentPageAiOpenEditorsSnapshot(...args); - const currentPageAiEditorTarget = (...args) => pageAiTargetRuntime.currentPageAiEditorTarget(...args); - const currentPageAiPageEditorTarget = (...args) => pageAiTargetRuntime.currentPageAiPageEditorTarget(...args); - const currentPageAiScopedEditorTarget = (...args) => pageAiTargetRuntime.currentPageAiScopedEditorTarget(...args); - const pageAiCloneJson = (...args) => pageAiTargetRuntime.pageAiCloneJson(...args); - const pageAiEditorTargetCandidates = (...args) => pageAiTargetRuntime.pageAiEditorTargetCandidates(...args); - const pageAiFallbackEditorTarget = (...args) => pageAiTargetRuntime.pageAiFallbackEditorTarget(...args); - const pageAiResourceKindForTarget = (...args) => pageAiTargetRuntime.pageAiResourceKindForTarget(...args); - const pageAiTargetFromOpenEditor = (...args) => pageAiTargetRuntime.pageAiTargetFromOpenEditor(...args); - const pageAiTargetId = (...args) => pageAiTargetRuntime.pageAiTargetId(...args); - const localMarkdownRelativePathFromPageAiDocumentId = (...args) => pageAiTargetRuntime.localMarkdownRelativePathFromPageAiDocumentId(...args); - const localMarkdownDocumentIdFromPageAiRelativePath = (...args) => pageAiTargetRuntime.localMarkdownDocumentIdFromPageAiRelativePath(...args); - const pageAiWorkspacePathForDocument = (...args) => pageAiTargetRuntime.pageAiWorkspacePathForDocument(...args); - const pageAiWorkspacePathForTarget = (...args) => pageAiTargetRuntime.pageAiWorkspacePathForTarget(...args); - const pageAiBuildAllowedRoots = (...args) => pageAiTargetRuntime.pageAiBuildAllowedRoots(...args); - const pageAiBuildContextRefs = (...args) => pageAiTargetRuntime.pageAiBuildContextRefs(...args); - const pageAiBuildRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiBuildRunTargetSnapshot(...args); - const pageAiPageContextForRefs = (...args) => pageAiTargetRuntime.pageAiPageContextForRefs(...args); - const pageAiSetRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiSetRunTargetSnapshot(...args); - const assertPageAiTargetInCurrentWorkspace = (...args) => pageAiTargetRuntime.assertPageAiTargetInCurrentWorkspace(...args); - const pageAiBuildAgentTargetPackage = (...args) => pageAiTargetRuntime.pageAiBuildAgentTargetPackage(...args); - const pageAiBlockingDirtyState = (...args) => pageAiTargetRuntime.pageAiBlockingDirtyState(...args); - const fetchPageAiTargetBufferState = (...args) => pageAiTargetRuntime.fetchPageAiTargetBufferState(...args); - const assertPageAiTargetWritable = (...args) => pageAiTargetRuntime.assertPageAiTargetWritable(...args); - const currentPageAiSelectedText = (...args) => pageAiTargetRuntime.currentPageAiSelectedText(...args); - const pageAiProjectionBlocks = (...args) => pageAiTargetRuntime.pageAiProjectionBlocks(...args); - const pageAiBlockText = (...args) => pageAiTargetRuntime.pageAiBlockText(...args); - const pageAiSelectedBlockIdsFromSelection = (...args) => pageAiTargetRuntime.pageAiSelectedBlockIdsFromSelection(...args); - const pageAiBlocksToPageXml = (...args) => pageAiTargetRuntime.pageAiBlocksToPageXml(...args); - const buildPageAiContext = (...args) => pageAiTargetRuntime.buildPageAiContext(...args); - const pageAiScopedPageContext = (...args) => pageAiTargetRuntime.pageAiScopedPageContext(...args); - pageAiRenderRuntime = createSidebarPageAiRenderRuntime({ - contextRefRegistry: PAGE_AI_CONTEXT_REF_REGISTRY, - cssEscape, - currentDocumentId, - currentPageAiEditorTarget, - currentPageAiSelectedText, - documentRef: document, - escapeHtml, - localMarkdownRelativePathFromPageAiDocumentId, - pageAiAgentDescriptor, - pageAiAgentRecord, - pageAiAllSkillEntries, - pageAiBuildAllowedRoots, - pageAiChatOnlyProfileEntries, - pageAiChatOnlyProfileSpec, - pageAiCurrentAgentId, - pageAiCurrentProfile, - pageAiCurrentProfileRecord, - pageAiCurrentSession, - pageAiCurrentSkillSource, - pageAiCurrentSkillSourceLabel, - pageAiDefaultChatOnlyProfileSpec, - pageAiDescriptorFieldOptions, - pageAiDescriptorFieldValue, - pageAiEditorTargetCandidates, - pageAiEnsureContextRefState, - pageAiFilteredHistoryRows, - pageAiHermesProfileEntries, - pageAiHermesSettingsUrl, - pageAiHideHermesBuiltinSkills, - pageAiMnoteToolModel, - pageAiNormalizeAcpRuntimes, - pageAiNormalizeArray, - pageAiNormalizeSkillSource, - pageAiProfileDisplayLabel, - pageAiProfileValue, - pageAiProviderLabel, - pageAiReasonixMemoryEnabled, - pageAiRunProfile, - pageAiSessionAgentFilterOptions, - pageAiSessionAgentFilterValue, - pageAiSessionAgentLabel, - pageAiSessionPreviewText, - pageAiSessionStatusFilterOptions, - pageAiSessionStatusFilterValue, - pageAiSessionStatusLabel, - pageAiSessionStorageLabel, - pageAiSessionSelected, - pageAiSelectedSessionCount, - pageAiSkillEnabled, - pageAiSkillGroupCollapsed, - pageAiSkillListEntries, - pageAiSkillOriginLabel, - pageAiSkillSourceOptions, - pageAiSkillSourceParts, - pageAiToggleableSkillEntries, - pageAiUsageSummary, - pageAiBoardFirstEnabled, - pageAiBoardWorkerOptions, - pageAiBoardWorkflowOptions, - pageAiBoardSelectedWorkerId, - pageAiBoardSelectedWorkflowId, - pageAiBoardWorkerLabel, - pageAiBoardWorkflowLabel, - pageAiBoardModelOptions, - pageAiBoardSelectedModelOverride, - pageAiBoardModelLabel, - pageUiState, - renderPageAiMarkdown, - searchText, - }); - - function pageAiSetAgentId(agentId) { - var next = pageAiNormalizeAgentId(agentId); - var record = pageAiAgentRecord(next); - pageUiState.pageAiAgentId = next; - pageUiState.pageAiAcpRuntime = record.acpRuntime || 'reasonix'; - if (record.tools) pageUiState.pageAiTools = record.tools; - pageUiState.pageAiAgentPopoverOpen = next === 'hermes'; - if (next === 'hermes') pageAiSetActiveProfile(pageAiCurrentProfile()); - if (next === 'chat_only') pageAiSetActiveProfile(pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile())); - document.documentElement.setAttribute('data-mnote-page-ai-agent-id', next); - pageAiPersistAiPreference('default_agent_id', next); - void pageAiLoadSkills(); - renderPageAiProviderButtons(); - renderPageAiControls(); - } - - function pageAiToggleContextRef(kind) { - var normalized = String(kind || '').trim(); - if (!PAGE_AI_CONTEXT_REF_REGISTRY.some(function(ref) { return ref.id === normalized; })) return; - var selected = pageAiEnsureContextRefState(); - selected[normalized] = !selected[normalized]; - if (!Object.keys(selected).some(function(key) { return selected[key]; })) selected.current_page = true; - pageAiPersistAiPreference('context_refs.default_selected', selected); - renderPageAiControls(); - } - - function pageAiSetContextPopoverOpen(open) { - pageUiState.pageAiContextPopoverOpen = Boolean(open); - if (open) { - pageUiState.pageAiAgentPopoverOpen = false; - pageUiState.pageAiTargetPopoverOpen = false; - } - renderPageAiControls(); - } - - function pageAiSetAgentPopoverOpen(open) { - pageUiState.pageAiAgentPopoverOpen = Boolean(open); - if (open) { - pageUiState.pageAiContextPopoverOpen = false; - pageUiState.pageAiTargetPopoverOpen = false; - } - renderPageAiControls(); - } - - function pageAiSetTargetPopoverOpen(open) { - pageUiState.pageAiTargetPopoverOpen = Boolean(open); - if (open) { - pageUiState.pageAiAgentPopoverOpen = false; - pageUiState.pageAiContextPopoverOpen = false; - } - renderPageAiControls(); - } - - function pageAiSelectTarget(targetId) { - var normalized = String(targetId || '').trim(); - var candidates = pageAiEditorTargetCandidates(); - var target = candidates.find(function(candidate) { return candidate.targetId === normalized; }) || null; - if (!target) return; - pageUiState.pageAiSelectedTargetId = target.targetId; - pageUiState.pageAiTargetPopoverOpen = false; - renderPageAiControls(); - } - - - function pageAiPersistAiPreference(key, value) { - pageAiPersistRawAiPreference('ai.common.' + key, value); - } - - function pageAiPersistRawAiPreference(key, value) { - try { - var workspaceId = resolveWorkspaceId(document.body); - var body = { - workspaceId: workspaceId, - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - documentId: currentDocumentId(), - updates: {} - }; - body.updates[key] = value; - void fetch('/api/ui/preferences', { - method: 'PUT', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - body: JSON.stringify(body) - }).catch(function() {}); - } catch (_) {} - } - - function pageAiDescriptorPreferenceValues() { - return pageUiState.pageAiSkillPreferences && typeof pageUiState.pageAiSkillPreferences === 'object' - ? pageUiState.pageAiSkillPreferences - : {}; - } - - function pageAiDescriptorFieldValue(field) { - var key = String(field && field.key || '').trim(); - var preferences = pageAiDescriptorPreferenceValues(); - if (key && Object.prototype.hasOwnProperty.call(preferences, key)) return preferences[key]; - return field && Object.prototype.hasOwnProperty.call(field, 'default') ? field.default : ''; - } - - function pageAiDescriptorFieldOptions(field) { - var source = String(field && field.optionsSource || '').trim(); - if (source === 'hermes_profiles') { - return pageAiHermesProfileEntries().map(function(profile) { - var value = pageAiProfileValue(profile); - return { - value: value, - label: pageAiProfileDisplayLabel(profile, value) - }; - }).filter(function(option) { return option.value; }); - } - if (source === 'chat_only_profiles') { - return pageAiChatOnlyProfileEntries().map(function(profile) { - var value = pageAiProfileValue(profile); - return { - value: value, - label: profile.menuLabel || pageAiProfileDisplayLabel(profile, value) - }; - }).filter(function(option) { return option.value; }); - } - return pageAiNormalizeArray(field && field.options).map(function(option) { - if (option && typeof option === 'object') { - var value = String(option.value || option.id || option.key || '').trim(); - return { - value: value, - label: String(option.label || option.title || value).trim() || value - }; - } - var value = String(option || '').trim(); - return { value: value, label: value }; - }).filter(function(option) { return option.value; }); - } - - function pageAiSetDescriptorFieldValue(key, value) { - var normalizedKey = String(key || '').trim(); - if (!normalizedKey) return; - pageUiState.pageAiSkillPreferences = Object.assign({}, pageAiDescriptorPreferenceValues(), { - [normalizedKey]: value - }); - pageAiPersistRawAiPreference(normalizedKey, value); - if (normalizedKey === 'ai.agent.hermes.profile_id') { - pageAiSetActiveProfile(value); - void pageAiLoadAgentDescriptors(); - void pageAiLoadSkills(); - void pageAiLoadTools(); - } - if (normalizedKey === 'ai.agent.chat_only.model_id' && pageAiCurrentAgentId() === 'chat_only') { - pageAiSetActiveProfile(pageAiNormalizeChatOnlyProfileId(value)); - } - renderPageAiControls(); - } - - function pageAiReasonixPreference(key, fallback) { - var preferences = pageAiDescriptorPreferenceValues(); - var value = preferences[key]; - if (value == null || String(value).trim() === '') return fallback; - return String(value).trim(); - } - - function pageAiReasonixControlState() { - return { - modelId: pageAiReasonixPreference('ai.agent.reasonix.model_id', 'deepseek-flash'), - approvalMode: pageAiReasonixPreference('ai.agent.reasonix.approval_mode', 'ask'), - planMode: pageAiReasonixPreference('ai.agent.reasonix.plan_mode', 'manual') - }; - } - - function pageAiApplyReasonixPlanMode(prompt) { - if (pageAiCurrentAgentId() !== 'reasonix') return prompt; - var control = pageAiReasonixControlState(); - if (control.planMode !== 'auto') return prompt; - return '请按 Reasonix 自动计划模式处理:复杂或会写文件的任务先给出简短计划,并等待我批准后再执行。\n\n' + prompt; - } - - function pageAiBoardFirstEnabled() { - try { - return window.localStorage ? window.localStorage.getItem('mnote.page_ai.legacy_provider_mode') !== '1' : true; - } catch (_error) { - return true; - } - } - - function pageAiOpencodeHostEnabled() { - try { - return window.localStorage ? window.localStorage.getItem('mnote.page_ai.opencode_host') === '1' : false; - } catch (_error) { - return false; - } - } - - function pageAiOpencodeStatusLabel(status) { - var node = status && typeof status === 'object' ? status : {}; - var state = String(node.status || node.state || node.runtimeStatus || '').trim(); - var ok = node.ok === true || node.ready === true || state === 'ready' || state === 'running' || state === 'ok'; - if (ok) return state ? '可用 · ' + state : '可用'; - if (state) return state; - return '未知'; - } - - function pageAiOpencodeSessionIdFromStatus(status) { - var node = status && typeof status === 'object' ? status : {}; - var session = node.session && typeof node.session === 'object' ? node.session : {}; - return String(node.sessionId || node.activeSessionId || node.currentSessionId || session.id || '').trim(); - } - - function pageAiOpencodeData(payload) { - return payload && payload.data && typeof payload.data === 'object' ? payload.data : payload; - } - - function pageAiOpencodeSessionArray(payload) { - var data = pageAiOpencodeData(payload); - return pageAiNormalizeArray(data && data.data) - .concat(pageAiNormalizeArray(data && data.sessions)) - .concat(Array.isArray(data) ? data : []); - } - - function pageAiOpencodeCollectChangedFileCandidates(value, output, depth) { - if (value == null || depth > 5) return; - if (typeof value === 'string') { - if (/\.(md|markdown|txt|json|ya?ml|toml|rs|js|ts|tsx|jsx|css|html)$/i.test(value) || value.indexOf('/') !== -1) { - output.push(value); - } - return; - } - if (Array.isArray(value)) { - value.forEach(function(item) { pageAiOpencodeCollectChangedFileCandidates(item, output, depth + 1); }); - return; - } - if (typeof value !== 'object') return; - var directPath = value.path || value.file || value.filename || value.relativePath || value.relative_path || value.absolutePath || value.absolute_path; - if (directPath) output.push(value); - ['changedFiles', 'changed_files', 'files', 'diff', 'diffs', 'changes', 'changed', 'edited', 'created', 'deleted', 'data', 'properties'].forEach(function(key) { - if (Object.prototype.hasOwnProperty.call(value, key)) pageAiOpencodeCollectChangedFileCandidates(value[key], output, depth + 1); - }); - } - - function pageAiOpencodeNormalizeChangedFiles(payload) { - var source = []; - pageAiOpencodeCollectChangedFileCandidates(payload, source, 0); - var seen = {}; - return source.map(function(item) { - var path = typeof item === 'string' - ? item - : String(item && (item.path || item.file || item.filename || item.relativePath || item.relative_path || item.absolutePath || item.absolute_path) || '').trim(); - var status = typeof item === 'object' && item ? String(item.status || item.change || item.kind || item.type || '').trim() : ''; - return { path: path, status: status }; - }).filter(function(item) { - if (!item.path || seen[item.path]) return false; - seen[item.path] = true; - return true; - }); - } - - function pageAiNormalizeRootRelativePath(path) { - return String(path || '').trim().replace(/\\/g, '/').replace(/^\/+/, ''); - } - - function pageAiRootPathFromFileUri(rootUri) { - var root = String(rootUri || '').trim(); - if (!root.startsWith('file://')) return ''; - try { - return decodeURIComponent(new URL(root).pathname || '').replace(/\/+$/, ''); - } catch (_error) { - return ''; - } - } - - function pageAiRootRelativePathFromChangedPath(path, rootUri) { - var value = String(path || '').trim(); - if (!value) return ''; - try { - var parsed = new URL(value, window.location.origin); - if (parsed.pathname === '/api/local-folder/files/open') { - return pageAiNormalizeRootRelativePath(parsed.searchParams.get('path') || ''); - } - if (parsed.protocol === 'file:') value = decodeURIComponent(parsed.pathname || ''); - } catch (_error) {} - value = value.replace(/^file:\/\//, ''); - var rootPath = pageAiRootPathFromFileUri(rootUri || currentRootUri()); - if (rootPath && value.startsWith(rootPath + '/')) { - value = value.slice(rootPath.length + 1); - } - return pageAiNormalizeRootRelativePath(value); - } - - function pageAiDocumentIdFromRootRelativePath(relativePath) { - var normalized = pageAiNormalizeRootRelativePath(relativePath); - if (!normalized || !/\.(md|markdown)$/i.test(normalized)) return ''; - return localMarkdownDocumentIdFromPageAiRelativePath(normalized); - } - - function pageAiOpencodeCurrentPageLabel() { - var aggregate = currentPageAggregate(); - var meta = aggregate && aggregate.meta && typeof aggregate.meta === 'object' ? aggregate.meta : {}; - var target = currentPageAiEditorTarget() || {}; - return String(meta.title || target.title || document.title || currentDocumentId() || '当前页').trim(); - } - - function pageAiOpencodePrimaryPath() { - var target = currentPageAiEditorTarget() || {}; - var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {}; - return String(workspacePath.relativePath || workspacePath.path || target.relativePath || localMarkdownRelativePathFromPageAiDocumentId(currentDocumentId()) || '').trim(); - } - - function pageAiOpencodeWritableLabel() { - var roots = pageAiNormalizeArray(pageUiState.pageAiAllowedRoots && pageUiState.pageAiAllowedRoots.length ? pageUiState.pageAiAllowedRoots : pageAiBuildAllowedRoots()); - if (!roots.length) return '授权待同步'; - var writable = roots.some(function(root) { - return /write|writable|rw/i.test(String(root && (root.permission || root.access || root.mode || '') || '')) || root && root.writable === true; - }); - return writable ? '可写' : '只读'; - } - - function pageAiFilePathFromRootUri(rootUri, relativePath) { - var root = String(rootUri || '').trim(); - var relative = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, ''); - if (!root || !relative || !root.startsWith('file://')) return ''; - try { - var pathname = new URL(root).pathname || ''; - return decodeURIComponent(pathname).replace(/\/+$/, '') + '/' + relative; - } catch (_error) { - return ''; - } - } - - function pageAiMNoteDocumentPathSegment(documentId) { - return encodeURIComponent(String(documentId || '').trim()).replace(/%3A/g, ':').replace(/%7E/g, '~'); - } - - function pageAiParentRelativePath(relativePath) { - var normalized = pageAiNormalizeRootRelativePath(relativePath); - if (!normalized || normalized.indexOf('/') === -1) return ''; - return normalized.split('/').slice(0, -1).join('/'); - } - - function pageAiFileUriForRootRelativePath(rootUri, relativePath) { - var root = String(rootUri || '').trim().replace(/\/+$/, ''); - var relative = pageAiNormalizeRootRelativePath(relativePath); - if (!root) return ''; - if (!relative) return root; - return root + '/' + relative.split('/').map(function(segment) { - return encodeURIComponent(segment); - }).join('/'); - } - - function pageAiCurrentActiveTabEditorTarget() { - var snapshot = currentPageAiOpenEditorsSnapshot(); - if (snapshot && snapshot.activeEditor) { - var activeTarget = pageAiTargetFromOpenEditor(snapshot.activeEditor, 'open_editors_active_tab'); - if (activeTarget) return activeTarget; - } - if (snapshot) { - var entries = pageAiNormalizeArray(snapshot.editors).concat(pageAiNormalizeArray(snapshot.resourceEditors)); - var activeEntry = entries.find(function(entry) { return entry && entry.active === true; }); - if (activeEntry) { - var target = pageAiTargetFromOpenEditor(activeEntry, 'open_editors_active_tab'); - if (target) return target; - } - } - return pageAiFallbackEditorTarget(); - } - - function pageAiCurrentActiveTabAddressPayload() { - var target = pageAiCurrentActiveTabEditorTarget() || {}; - var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {}; - var rootUri = String(workspacePath.rootUri || target.rootUri || currentRootUri() || '').trim(); - var sourceKind = String(workspacePath.sourceKind || target.sourceKind || currentSourceKind() || '').trim(); - var workspaceId = String(workspacePath.workspaceId || target.workspaceId || resolveWorkspaceId(document.body) || '').trim(); - var documentId = String(workspacePath.documentId || target.documentId || currentDocumentId() || '').trim(); - var relativePath = pageAiNormalizeRootRelativePath(workspacePath.relativePath || workspacePath.path || target.relativePath || target.path || ''); - if (!relativePath) relativePath = pageAiNormalizeRootRelativePath(localMarkdownRelativePathFromPageAiDocumentId(documentId)); - if (!documentId && relativePath && /\.(md|markdown)$/i.test(relativePath)) { - documentId = localMarkdownDocumentIdFromPageAiRelativePath(relativePath); - } - if (!documentId) documentId = String(currentDocumentId() || '').trim(); - var resourceKind = String(target.resourceKind || workspacePath.resourceKind || target.editorKind || '').trim().toLowerCase(); - var editorKind = String(target.editorKind || '').trim().toLowerCase(); - var objectIdentity = String(target.objectIdentity || target.targetId || workspacePath.objectIdentity || '').trim(); - var tabUrl = new URL('/documents/' + pageAiMNoteDocumentPathSegment(documentId), window.location.origin); - if (sourceKind) tabUrl.searchParams.set('sourceKind', sourceKind); - if (rootUri) tabUrl.searchParams.set('rootUri', rootUri); - if (workspaceId) tabUrl.searchParams.set('workspaceId', workspaceId); - var isResourceTab = Boolean(relativePath) - && resourceKind !== 'markdown_page' - && resourceKind !== 'page' - && editorKind !== 'page'; - if (isResourceTab) { - tabUrl.searchParams.set('resourcePath', relativePath); - if (objectIdentity) tabUrl.searchParams.set('resourceTab', objectIdentity); - } - var folderRelativePath = pageAiParentRelativePath(relativePath); - var folderUrl = pageAiFileUriForRootRelativePath(rootUri, folderRelativePath); - if (!folderUrl) { - var folderMNoteUrl = new URL('/documents/' + pageAiMNoteDocumentPathSegment(documentId), window.location.origin); - if (sourceKind) folderMNoteUrl.searchParams.set('sourceKind', sourceKind); - if (rootUri) folderMNoteUrl.searchParams.set('rootUri', rootUri); - if (workspaceId) folderMNoteUrl.searchParams.set('workspaceId', workspaceId); - if (folderRelativePath) folderMNoteUrl.searchParams.set('fileTreeScope', folderRelativePath); - folderUrl = folderMNoteUrl.toString(); - } - return { - schema: 'mnote.page_ai.active_tab_address.v1', - tabUrl: tabUrl.toString(), - folderUrl: folderUrl, - documentId: documentId, - rootUri: rootUri, - workspaceId: workspaceId, - relativePath: relativePath, - folderRelativePath: folderRelativePath, - targetId: String(target.targetId || objectIdentity || '').trim(), - resourceKind: resourceKind || 'markdown_page' - }; - } - - function pageAiBuildOpencodeContextPayload() { - var selectedText = currentPageAiSelectedText(); - var target = currentPageAiEditorTarget() || {}; - var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {}; - var pagePath = pageAiOpencodePrimaryPath(); - var rootUri = String(workspacePath.rootUri || target.rootUri || currentRootUri() || '').trim(); - return { - pageTitle: pageAiOpencodeCurrentPageLabel(), - pagePath: pagePath, - pageAbsolutePath: pageAiFilePathFromRootUri(rootUri, pagePath), - rootUri: rootUri, - selection: selectedText, - allowedRoots: pageAiNormalizeArray(pageUiState.pageAiAllowedRoots && pageUiState.pageAiAllowedRoots.length ? pageUiState.pageAiAllowedRoots : pageAiBuildAllowedRoots()), - pageId: String(currentDocumentId() || target.documentId || target.id || ''), - workspaceId: String(target.workspaceId || ''), - }; - } - - function pageAiOpencodeContextTextForInsert() { - var context = pageAiBuildOpencodeContextPayload(); - var lines = ['MNote 当前页上下文']; - if (context.pageTitle) lines.push('标题:' + context.pageTitle); - if (context.pagePath) lines.push('文件:' + context.pagePath); - if (context.selection) lines.push('选区:\n' + context.selection); - var roots = pageAiNormalizeArray(context.allowedRoots).map(function(root) { - return String(root && (root.path || root.root || root.uri || root.label) || '').trim(); - }).filter(Boolean).slice(0, 4); - if (roots.length) lines.push('允许目录:' + roots.join(', ')); - return lines.join('\n'); - } - - function pageAiPostOpencodeBridgeMessage(type, payload) { - var iframe = document.querySelector('[data-page-ai-opencode-iframe]'); - if (!(iframe instanceof HTMLIFrameElement) || !iframe.contentWindow) return false; - iframe.contentWindow.postMessage({ source: 'mnote-page-ai', target: 'opencode-webui', type: type, payload: payload || {} }, window.location.origin); - return true; - } - - function pageAiInstallOpencodeBridge() { - if (pageUiState.pageAiOpencodeBridgeInstalled) return; - pageUiState.pageAiOpencodeBridgeInstalled = true; - window.addEventListener('message', function(event) { - if (event.origin !== window.location.origin) return; - var message = event.data && typeof event.data === 'object' ? event.data : null; - if (!message || message.source !== 'mnote-opencode-bridge') return; - var payload = message.payload && typeof message.payload === 'object' ? message.payload : {}; - if (message.type === 'session-ready') { - pageUiState.pageAiOpencodeBridgeReady = true; - pageAiRenderOpencodeHostChrome(); - return; - } - if (message.type === 'open-file') { - pageAiOpenOpencodeChangedFile(payload.path || payload.href || payload.text || ''); - return; - } - if (message.type === 'refresh-file') { - pageAiRefreshOpencodeCurrentPage('page-ai-opencode-bridge'); - } - }); - } - - function pageAiOpencodeMessageInfo(message) { - return message && message.info && typeof message.info === 'object' ? message.info : message || {}; - } - - function pageAiOpencodePartText(part) { - if (!part || typeof part !== 'object') return ''; - if (part.text) return String(part.text); - if (part.content) return String(part.content); - if (part.summary) return String(part.summary); - if (part.type === 'patch') return '修改文件:' + pageAiNormalizeArray(part.files).join(', '); - if (part.type === 'file') return String(part.filename || part.url || 'file'); - if (part.type === 'tool') return String(part.tool || part.name || 'tool'); - return ''; - } - - function pageAiOpencodeIsContextEnvelope(message, parts) { - var info = pageAiOpencodeMessageInfo(message); - var text = String(info.text || '').trim() || pageAiNormalizeArray(parts).map(pageAiOpencodePartText).join('\n').trim(); - return text.indexOf('MNote context envelope') === 0; - } - - function pageAiOpencodeNormalizeMessages(payload) { - var data = pageAiOpencodeData(payload); - var items = pageAiNormalizeArray(data && data.data) - .concat(pageAiNormalizeArray(data && data.messages)) - .concat(Array.isArray(data) ? data : []); - return items.map(function(item) { - var info = pageAiOpencodeMessageInfo(item); - var parts = pageAiNormalizeArray(item && item.parts).concat(pageAiNormalizeArray(info.content)); - var type = String(info.role || info.type || item && item.type || '').toLowerCase(); - var role = type.indexOf('assistant') !== -1 ? 'assistant' : type.indexOf('user') !== -1 ? 'user' : type || 'event'; - return { - id: String(info.id || item && item.id || Math.random()).trim(), - role: role, - type: type, - agent: String(info.agent || ''), - model: info.model || null, - finish: String(info.finish || ''), - error: info.error || null, - cost: info.cost, - tokens: info.tokens || null, - parts: parts, - hiddenContext: pageAiOpencodeIsContextEnvelope(item, parts), - text: String(info.text || '').trim(), - }; - }).filter(function(message) { - return !message.hiddenContext && (message.text || message.parts.length || message.error || message.type); - }); - } - - function pageAiOpencodeNormalizePermissions(payload) { - var data = pageAiOpencodeData(payload); - return pageAiNormalizeArray(data && data.data) - .concat(pageAiNormalizeArray(data && data.permissions)) - .concat(Array.isArray(data) ? data : []); - } - - function pageAiOpencodeNormalizeTodo(payload) { - var data = pageAiOpencodeData(payload); - return pageAiNormalizeArray(data && data.data).concat(pageAiNormalizeArray(data && data.todo)).concat(Array.isArray(data) ? data : []); - } - - function pageAiOpencodeCompact(value, max) { - var text = typeof value === 'string' ? value : JSON.stringify(value || {}); - max = max || 260; - return text.length > max ? text.slice(0, max - 1) + '…' : text; - } - - function pageAiRenderOpencodePart(part) { - if (!part || typeof part !== 'object') return ''; - var type = String(part.type || 'part'); - if (type === 'text') return '
    ' + escapeHtml(pageAiOpencodePartText(part)).replace(/\n/g, '
    ') + '
    '; - if (type === 'reasoning') return '
    reasoning
    ' + escapeHtml(pageAiOpencodePartText(part)) + '
    '; - if (type === 'tool') { - var state = part.state && typeof part.state === 'object' ? part.state : {}; - var status = String(state.status || state.type || part.status || 'pending'); - return '
    ' + escapeHtml(part.tool || 'tool') + '' + escapeHtml(status) + '
    ' + escapeHtml(pageAiOpencodeCompact(state.output || state.input || state.error || state)) + '
    '; - } - if (type === 'patch') { - var files = pageAiNormalizeArray(part.files); - return '
    patch' + files.map(function(file) { - var path = String(file || '').trim(); - return ''; - }).join('') + '
    '; - } - if (type === 'file') { - var filePath = String(part.filename || part.url || '').trim(); - return '
    file
    '; - } - if (type === 'step-start' || type === 'step-finish' || type === 'snapshot' || type === 'agent' || type === 'subtask' || type === 'retry' || type === 'compaction') { - return '
    ' + escapeHtml(type) + '
    ' + escapeHtml(pageAiOpencodeCompact(part, 420)) + '
    '; - } - return '
    ' + escapeHtml(type) + '
    ' + escapeHtml(pageAiOpencodeCompact(part, 420)) + '
    '; - } - - function pageAiRenderOpencodeMessages(drawer) { - var node = drawer.querySelector('[data-page-ai-opencode-messages]'); - if (!(node instanceof HTMLElement)) return; - var messages = pageAiNormalizeArray(pageUiState.pageAiOpencodeMessages); - if (!messages.length) { - node.innerHTML = '
    输入消息后由 opencode 原生 runtime 回复;tool、reasoning、patch 会在这里显示。
    '; - return; - } - node.innerHTML = messages.map(function(message) { - var model = message.model && typeof message.model === 'object' ? String(message.model.providerID || message.model.provider || '') + '/' + String(message.model.id || message.model.modelID || '') : ''; - var parts = pageAiNormalizeArray(message.parts).map(pageAiRenderOpencodePart).join('') || (message.text ? '
    ' + escapeHtml(message.text).replace(/\n/g, '
    ') + '
    ' : ''); - var error = message.error ? '
    ' + escapeHtml(pageAiOpencodeCompact(message.error, 500)) + '
    ' : ''; - return '
    ' + - '
    ' + escapeHtml(message.role === 'assistant' ? 'opencode' : message.role === 'user' ? '你' : message.type || 'event') + '' + escapeHtml([message.agent, model, message.finish].filter(Boolean).join(' · ')) + '
    ' + - parts + error + - '
    '; - }).join(''); - node.scrollTop = node.scrollHeight; - } - - function pageAiRenderOpencodePermissions(drawer) { - var node = drawer.querySelector('[data-page-ai-opencode-permissions]'); - if (!(node instanceof HTMLElement)) return; - var permissions = pageAiNormalizeArray(pageUiState.pageAiOpencodePermissions); - node.innerHTML = permissions.map(function(item) { - var id = String(item && item.id || '').trim(); - var action = String(item && item.action || 'permission').trim(); - var resources = pageAiNormalizeArray(item && item.resources).join(', '); - return '
    ' + - '' + escapeHtml(action) + '' + - '' + escapeHtml(resources || '等待审批') + '' + - '' + - '' + - '' + - '
    '; - }).join(''); - } - - function pageAiRenderOpencodeSessions(drawer) { - var node = drawer.querySelector('[data-page-ai-opencode-sessions]'); - if (!(node instanceof HTMLElement)) return; - var sessions = pageAiNormalizeArray(pageUiState.pageAiOpencodeSessions).slice(0, 12); - node.innerHTML = sessions.length ? sessions.map(function(session) { - var id = String(session && session.id || '').trim(); - var active = id && id === String(pageUiState.pageAiOpencodeSessionId || ''); - var title = String(session && (session.title || session.name || session.id) || 'session').trim(); - var model = session && session.model && typeof session.model === 'object' ? String(session.model.providerID || '') + '/' + String(session.model.id || session.model.modelID || '') : ''; - return ''; - }).join('') : '暂无 session 列表'; - } - - function pageAiRenderOpencodeTodo(drawer) { - var node = drawer.querySelector('[data-page-ai-opencode-todo]'); - if (!(node instanceof HTMLElement)) return; - var todo = pageAiNormalizeArray(pageUiState.pageAiOpencodeTodo); - node.innerHTML = todo.length ? todo.map(function(item) { - return '
  • ' + escapeHtml(String(item && (item.content || item.text || item.title) || item || '')) + '
  • '; - }).join('') : ''; - } - - function pageAiRenderOpencodeHostChrome() { - var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - if (!(drawer instanceof HTMLElement) || drawer.getAttribute('data-page-ai-opencode-host') !== 'true') return; - var status = pageUiState.pageAiOpencodeStatus || {}; - var session = status.session && typeof status.session === 'object' ? status.session : status; - var sessionId = String(pageUiState.pageAiOpencodeSessionId || pageAiOpencodeSessionIdFromStatus(status) || '').trim(); - var primaryPath = pageAiOpencodePrimaryPath(); - var titleNode = drawer.querySelector('[data-page-ai-opencode-current-page]'); - if (titleNode instanceof HTMLElement) titleNode.textContent = pageAiOpencodeCurrentPageLabel(); - var pathNode = drawer.querySelector('[data-page-ai-opencode-current-path]'); - if (pathNode instanceof HTMLElement) pathNode.textContent = primaryPath || '未定位真实 Markdown 路径'; - var selectionNode = drawer.querySelector('[data-page-ai-opencode-selection]'); - if (selectionNode instanceof HTMLElement) selectionNode.textContent = currentPageAiSelectedText() ? '已选中 ' + currentPageAiSelectedText().length + ' 字' : '无选区'; - var writableNode = drawer.querySelector('[data-page-ai-opencode-writable]'); - if (writableNode instanceof HTMLElement) writableNode.textContent = pageAiOpencodeWritableLabel(); - var statusNode = drawer.querySelector('[data-page-ai-opencode-runtime-status]'); - if (statusNode instanceof HTMLElement) { - statusNode.textContent = pageUiState.pageAiOpencodeStatusError || pageAiOpencodeStatusLabel(status); - statusNode.setAttribute('data-state', pageUiState.pageAiOpencodeStatusError ? 'error' : pageAiOpencodeStatusLabel(status)); - } - var sessionNode = drawer.querySelector('[data-page-ai-opencode-session]'); - if (sessionNode instanceof HTMLElement) sessionNode.textContent = sessionId ? 'session ' + sessionId : '未绑定 session'; - var modelNode = drawer.querySelector('[data-page-ai-opencode-model]'); - if (modelNode instanceof HTMLElement) { - var model = session.model && typeof session.model === 'object' ? String(session.model.providerID || '') + '/' + String(session.model.id || session.model.modelID || '') : ''; - modelNode.textContent = model || 'model 未知'; - } - var chipsNode = drawer.querySelector('[data-page-ai-opencode-changed-files]'); - if (chipsNode instanceof HTMLElement) { - var changedFiles = pageAiNormalizeArray(pageUiState.pageAiOpencodeChangedFiles); - chipsNode.innerHTML = changedFiles.length ? changedFiles.map(function(file) { - var path = String(file && file.path || '').trim(); - var label = path.split('/').filter(Boolean).pop() || path; - var statusText = String(file && file.status || '').trim(); - return ''; - }).join('') : '' + escapeHtml(pageUiState.pageAiOpencodeDiffError || '暂无 changed files') + ''; - } - var iframe = drawer.querySelector('[data-page-ai-opencode-iframe]'); - if (iframe instanceof HTMLIFrameElement) { - var nextUrl = String(pageUiState.pageAiOpencodeIframeUrl || '').trim(); - if (nextUrl && iframe.getAttribute('src') !== nextUrl) iframe.setAttribute('src', nextUrl); - iframe.hidden = !nextUrl; - } - var fallbackNode = drawer.querySelector('[data-page-ai-opencode-iframe-fallback]'); - if (fallbackNode instanceof HTMLElement) { - var hasUrl = Boolean(String(pageUiState.pageAiOpencodeIframeUrl || '').trim()); - fallbackNode.hidden = hasUrl && !pageUiState.pageAiOpencodeStatusError; - fallbackNode.textContent = pageUiState.pageAiOpencodeStatusError - ? 'opencode WebUI 暂不可用:' + pageUiState.pageAiOpencodeStatusError - : '正在连接 opencode WebUI…'; - } - } - - function pageAiEnsureOpencodeHostDrawer() { - var drawer = ensurePageAiDrawer(); - if (drawer.getAttribute('data-page-ai-opencode-host') !== 'true') { - drawer.setAttribute('data-page-ai-opencode-host', 'true'); - drawer.setAttribute('data-mnote-acp-runtime', 'opencode'); - drawer.innerHTML = '' + - '' + - ''; - } - pageAiInstallOpencodeBridge(); - pageAiRenderOpencodeHostChrome(); - void pageAiEnsureOpencodeSessionBinding(false); - return drawer; - } - - async function pageAiEnsureOpencodeSessionBinding(force) { - if (pageUiState.pageAiOpencodeBindingInFlight) return; - var binding = pageUiState.pageAiOpencodeStatus && typeof pageUiState.pageAiOpencodeStatus === 'object' ? pageUiState.pageAiOpencodeStatus : {}; - var context = pageAiBuildOpencodeContextPayload(); - var pathSignature = JSON.stringify([context.pageTitle, context.pagePath, context.pageAbsolutePath, context.rootUri, context.selection.length, JSON.stringify(context.allowedRoots).slice(0, 512)]); - if (!force && pageUiState.pageAiOpencodeSessionId && pageUiState.pageAiOpencodeBindingKey === pathSignature) return; - pageUiState.pageAiOpencodeBindingInFlight = true; - pageUiState.pageAiOpencodeBindingKey = pathSignature; - try { - var response = await fetch('/api/page-ai/opencode/session', { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - cache: 'no-store', - body: JSON.stringify(Object.assign({ sessionId: force ? pageUiState.pageAiOpencodeSessionId || undefined : undefined, forceNew: force && !pageUiState.pageAiOpencodeSessionId }, context)), - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) throw new Error(payload && payload.error ? String(payload.error) : 'HTTP ' + response.status); - pageUiState.pageAiOpencodeStatus = payload.session || binding; - pageUiState.pageAiOpencodeStatusError = ''; - pageUiState.pageAiOpencodeSessionId = String(payload.sessionId || '').trim(); - pageUiState.pageAiOpencodeIframeUrl = String(payload.iframeUrl || pageUiState.pageAiOpencodeIframeUrl || '').trim(); - pageAiStartOpencodeEvents(); - void pageAiRefreshOpencodeProjection(); - pageAiRenderOpencodeHostChrome(); - } catch (error) { - pageUiState.pageAiOpencodeStatusError = error instanceof Error ? error.message : String(error); - pageUiState.pageAiOpencodeSending = false; - pageAiRenderOpencodeHostChrome(); - } finally { - pageUiState.pageAiOpencodeBindingInFlight = false; - } - } - - async function pageAiLoadOpencodeStatus() { - try { - var response = await fetch('/api/page-ai/opencode/status', { 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 ? String(payload.error) : 'HTTP ' + response.status); - pageUiState.pageAiOpencodeStatus = payload || {}; - pageUiState.pageAiOpencodeStatusError = ''; - pageUiState.pageAiOpencodeBaseUrl = String(payload && payload.baseUrl || pageUiState.pageAiOpencodeBaseUrl || 'http://127.0.0.1:4096').trim(); - pageUiState.pageAiOpencodeSessionId = pageAiOpencodeSessionIdFromStatus(payload) || pageUiState.pageAiOpencodeSessionId; - pageAiRenderOpencodeHostChrome(); - await pageAiRefreshOpencodeProjection(); - } catch (error) { - pageUiState.pageAiOpencodeStatusError = error instanceof Error ? error.message : String(error); - pageUiState.pageAiOpencodeSending = false; - pageAiRenderOpencodeHostChrome(); - } - } - - async function pageAiLoadOpencodeSessions() { - try { - var response = await fetch('/api/page-ai/opencode/sessions', { 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 ? String(payload.error) : 'HTTP ' + response.status); - pageUiState.pageAiOpencodeSessions = pageAiOpencodeSessionArray(payload && payload.sessions || payload); - pageAiRenderOpencodeHostChrome(); - } catch (error) { - pageUiState.pageAiOpencodeStatusError = error instanceof Error ? error.message : String(error); - pageUiState.pageAiOpencodeSending = false; - pageAiRenderOpencodeHostChrome(); - } - } - - async function pageAiLoadOpencodeMessages() { - var sessionId = String(pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!sessionId) return; - var response = await fetch('/api/page-ai/opencode/messages?sessionId=' + encodeURIComponent(sessionId), { 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 ? String(payload.error) : 'HTTP ' + response.status); - pageUiState.pageAiOpencodeMessages = pageAiOpencodeNormalizeMessages(payload); - pageAiRenderOpencodeHostChrome(); - } - - async function pageAiLoadOpencodePermissions() { - var sessionId = String(pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!sessionId) return; - var response = await fetch('/api/page-ai/opencode/permissions?sessionId=' + encodeURIComponent(sessionId), { 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 ? String(payload.error) : 'HTTP ' + response.status); - pageUiState.pageAiOpencodePermissions = pageAiOpencodeNormalizePermissions(payload); - pageAiRenderOpencodeHostChrome(); - } - - async function pageAiLoadOpencodeTodo() { - var sessionId = String(pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!sessionId) return; - var response = await fetch('/api/page-ai/opencode/todo?sessionId=' + encodeURIComponent(sessionId), { headers: { accept: 'application/json' }, cache: 'no-store' }); - var payload = await response.json().catch(function() { return null; }); - if (response.ok) pageUiState.pageAiOpencodeTodo = pageAiOpencodeNormalizeTodo(payload); - pageAiRenderOpencodeHostChrome(); - } - - async function pageAiLoadOpencodeDiff(sessionId) { - var id = String(sessionId || pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!id) return; - try { - var response = await fetch('/api/page-ai/opencode/diff?sessionId=' + encodeURIComponent(id), { 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 ? String(payload.error) : 'HTTP ' + response.status); - pageUiState.pageAiOpencodeChangedFiles = pageAiOpencodeNormalizeChangedFiles(payload); - pageUiState.pageAiOpencodeDiffError = ''; - pageAiRenderOpencodeHostChrome(); - if (pageAiOpencodeChangedFilesHitCurrentPage(pageUiState.pageAiOpencodeChangedFiles)) pageAiRefreshOpencodeCurrentPage('page-ai-opencode-event'); - } catch (error) { - pageUiState.pageAiOpencodeDiffError = error instanceof Error ? error.message : String(error); - pageAiRenderOpencodeHostChrome(); - } - } - - async function pageAiRefreshOpencodeProjection() { - if (!pageUiState.pageAiOpencodeSessionId) return; - await Promise.allSettled([ - pageAiLoadOpencodeMessages(), - pageAiLoadOpencodePermissions(), - pageAiLoadOpencodeTodo(), - pageAiLoadOpencodeDiff(pageUiState.pageAiOpencodeSessionId), - pageAiLoadOpencodeSessions(), - ]); - } - - function pageAiOpencodeChangedFilesHitCurrentPage(files) { - var currentPath = String(pageAiOpencodePrimaryPath() || '').trim(); - if (!currentPath) return false; - var normalizedCurrent = currentPath.replace(/\\/g, '/'); - return pageAiNormalizeArray(files).some(function(file) { - var path = String(file && file.path || '').trim().replace(/\\/g, '/'); - return path && (path === normalizedCurrent || path.endsWith('/' + normalizedCurrent.split('/').pop())); - }); - } - - function pageAiOpencodeEventMatchesSession(payload, sessionId) { - var id = String(sessionId || '').trim(); - if (!id) return false; - var node = payload && typeof payload === 'object' ? (payload.properties || payload.data || payload) : {}; - if (String(node.sessionID || node.sessionId || '').trim() === id) return true; - var text = ''; - try { text = JSON.stringify(payload || {}); } catch (_error) { text = ''; } - return text.indexOf(id) !== -1; - } - - function pageAiScheduleOpencodeRefresh(reason) { - if (!pageUiState.pageAiOpencodeSessionId) return; - if (pageUiState.pageAiOpencodeEventTimer) window.clearTimeout(pageUiState.pageAiOpencodeEventTimer); - pageUiState.pageAiOpencodeEventTimer = window.setTimeout(function() { - pageUiState.pageAiOpencodeEventTimer = 0; - void pageAiRefreshOpencodeProjection(); - }, reason === 'open' ? 250 : 700); - } - - function pageAiStartOpencodeEvents() { - var sessionId = String(pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!sessionId || !('EventSource' in window)) return; - var existing = pageUiState.pageAiOpencodeEventSource; - if (existing && pageUiState.pageAiOpencodeEventSessionId === sessionId) return; - if (existing && typeof existing.close === 'function') existing.close(); - var source = new EventSource('/api/page-ai/opencode/events'); - pageUiState.pageAiOpencodeEventSource = source; - pageUiState.pageAiOpencodeEventSessionId = sessionId; - var handleEvent = function(event) { - var payload = null; - try { payload = event && event.data ? JSON.parse(event.data) : null; } catch (_error) { payload = event && event.data ? { raw: String(event.data) } : null; } - var eventType = String(event && event.type || payload && (payload.type || payload.event) || 'message'); - var changedFiles = pageAiOpencodeNormalizeChangedFiles(payload); - if (changedFiles.length) { - pageUiState.pageAiOpencodeChangedFiles = changedFiles; - pageUiState.pageAiOpencodeDiffError = ''; - if (pageAiOpencodeChangedFilesHitCurrentPage(changedFiles)) pageAiRefreshOpencodeCurrentPage('page-ai-opencode-event'); - pageAiRenderOpencodeHostChrome(); - } - if (pageAiOpencodeEventMatchesSession(payload, sessionId) || /diff|file|permission|session|message/.test(eventType)) pageAiScheduleOpencodeRefresh('event'); - }; - source.onopen = function() { pageAiScheduleOpencodeRefresh('open'); }; - source.onmessage = handleEvent; - ['message.updated', 'message.part.updated', 'message.part.delta', 'session.diff', 'session.error', 'session.updated', 'session.next.text.delta', 'session.next.tool.called', 'session.next.tool.success', 'permission.v2.asked', 'permission.v2.replied', 'file.edited'].forEach(function(type) { - source.addEventListener(type, handleEvent); - }); - source.onerror = function() { - pageUiState.pageAiOpencodeDiffError = pageUiState.pageAiOpencodeDiffError || 'opencode event 暂不可用,保留手动刷新'; - pageAiRenderOpencodeHostChrome(); - }; - } - - async function pageAiSendOpencodeMessage(text) { - var sessionId = String(pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!sessionId) await pageAiEnsureOpencodeSessionBinding(true); - sessionId = String(pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!sessionId) throw new Error('opencode session 未就绪'); - pageUiState.pageAiOpencodeMessages = pageAiNormalizeArray(pageUiState.pageAiOpencodeMessages); - pageUiState.pageAiOpencodeSending = true; - pageAiRenderOpencodeHostChrome(); - var response = await fetch('/api/page-ai/opencode/prompt', { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - cache: 'no-store', - body: JSON.stringify({ sessionId: sessionId, text: text, model: PAGE_AI_OPENCODE_MODEL }) - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) throw new Error(payload && payload.error ? String(payload.error) : 'HTTP ' + response.status); - pageUiState.pageAiOpencodeSending = false; - pageAiScheduleOpencodeRefresh('prompt'); - } - - async function pageAiReplyOpencodePermission(id, response) { - var sessionId = String(pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!sessionId || !id) return; - await fetch('/api/page-ai/opencode/permission/reply', { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - cache: 'no-store', - body: JSON.stringify({ sessionId: sessionId, requestId: id, response: response }) - }); - void pageAiLoadOpencodePermissions(); - } - - async function pageAiAbortOpencodeRun() { - var sessionId = String(pageUiState.pageAiOpencodeSessionId || '').trim(); - if (!sessionId) return; - await fetch('/api/page-ai/opencode/abort', { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - cache: 'no-store', - body: JSON.stringify({ sessionId: sessionId }) - }); - pageAiScheduleOpencodeRefresh('abort'); - } - - function pageAiSelectOpencodeSession(sessionId) { - var id = String(sessionId || '').trim(); - if (!id) return; - pageUiState.pageAiOpencodeSessionId = id; - pageUiState.pageAiOpencodeMessages = []; - pageUiState.pageAiOpencodeLocalMessages = []; - pageAiStartOpencodeEvents(); - void pageAiRefreshOpencodeProjection(); - pageAiRenderOpencodeHostChrome(); - } - - function pageAiOpenOpencodeChangedFile(path, options) { - var rootUri = String(options && options.rootUri || currentRootUri() || '').trim(); - var targetPath = pageAiRootRelativePathFromChangedPath(path, rootUri); - if (!targetPath || !window.__mnoteDocumentPaneRuntime) return false; - var documentId = String(options && options.documentId || pageAiDocumentIdFromRootRelativePath(targetPath) || '').trim(); - var workspaceId = String(options && options.workspaceId || resolveWorkspaceId(document.body) || '').trim(); - var isMarkdown = Boolean(documentId); - if (isMarkdown) { - if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') { - window.__mnoteLocalFolderEventBus.emitChangedFiles({ - source: 'opencode_changed_file_bridge', - reason: 'opencode_changed_file', - rootUri: rootUri, - workspaceId: workspaceId, - changedFiles: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }], - affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: 'opencode_changed_file' }] - }); - } - if (typeof window.__mnoteDocumentPaneRuntime.openPrimaryDocument === 'function') { - if (documentId === String(currentDocumentId() || '').trim()) { - document.documentElement.setAttribute('data-mnote-page-ai-opencode-document-pane-refresh', targetPath); - return true; - } - void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({ - documentId: documentId, - workspaceId: workspaceId, - sourceKind: currentSourceKind() || 'local_folder', - rootUri: rootUri - }); - document.documentElement.setAttribute('data-mnote-page-ai-opencode-document-pane-refresh', targetPath); - return true; - } - } - if (typeof window.__mnoteDocumentPaneRuntime.openResourceInActiveTab !== 'function') return false; - void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({ path: targetPath, rootUri: rootUri }); - return true; - } - - function pageAiRefreshOpencodeCurrentPage(reason) { - var rootUri = String(currentRootUri() || '').trim(); - var targetPath = String(pageAiOpencodePrimaryPath() || '').trim(); - var documentId = String(currentDocumentId() || pageAiDocumentIdFromRootRelativePath(targetPath) || '').trim(); - if (rootUri && targetPath && documentId && window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') { - window.__mnoteLocalFolderEventBus.emitChangedFiles({ - source: 'opencode_changed_files_adapter', - reason: reason || 'page-ai-opencode-host', - rootUri: rootUri, - workspaceId: resolveWorkspaceId(document.body), - changedFiles: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }], - affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: reason || 'page-ai-opencode-host' }] - }); - return true; - } - return false; - } - - function pageAiApplyBoardFirstShell() { - if (!pageAiBoardFirstEnabled()) return; - var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - if (!(drawer instanceof HTMLElement)) return; - if (drawer.getAttribute('data-page-ai-opencode-host') === 'true') return; - drawer.setAttribute('data-page-ai-board-first', 'true'); - var subtitle = drawer.querySelector('.wolai-page-ai-subtitle'); - if (subtitle instanceof HTMLElement) { - subtitle.innerHTML = 'Agent BoardZCode 默认 developerlocal-first 文件编辑'; - } - var runtimeStrip = drawer.querySelector('[data-page-ai-runtime-strip]'); - if (runtimeStrip instanceof HTMLElement) { - runtimeStrip.setAttribute('aria-label', '打开 Agent Board 运行状态'); - } - var runtimeBadge = drawer.querySelector('[data-page-ai-runtime-badge]'); - if (runtimeBadge instanceof HTMLElement) runtimeBadge.textContent = 'Agent Board · ' + pageAiBoardWorkflowLabel(); - var agentChip = drawer.querySelector('.wolai-page-ai-agent-chip'); - if (agentChip instanceof HTMLElement) { - agentChip.textContent = pageAiBoardWorkerLabel() + ' / ' + pageAiBoardModelLabel(); - agentChip.setAttribute('title', '当前 worker/model:' + pageAiBoardWorkerLabel() + ' / ' + pageAiBoardModelLabel()); - } - drawer.querySelectorAll('[data-page-ai-tab="agent"], [data-page-ai-tab="reasonix-settings"], [data-page-ai-tab="hermes-settings"]').forEach(function(node) { - if (node instanceof HTMLElement) node.hidden = true; - }); - drawer.querySelectorAll('[data-page-ai-panel="agent"], [data-page-ai-panel="reasonix-settings"], [data-page-ai-panel="hermes-settings"]').forEach(function(node) { - if (node instanceof HTMLElement) node.setAttribute('data-page-ai-legacy-provider-panel', 'true'); - }); - } - - function pageAiBoardDefaultWorkerId() { - return 'mnote-page-ai-zcode'; - } - - function pageAiBoardDefaultWorkflowId() { - return 'builtin-mnote-page-ai-chat'; - } - - function pageAiBoardDefaultModelOverride() { - return 'zcode-default'; - } - - function pageAiBoardDefaultModelOptions() { - return [ - { id: 'zcode-default', label: '默认', default: true }, - { id: 'zcode-fast', label: '快速' }, - { id: 'zcode-strong', label: '强力' } - ]; - } - - function pageAiBoardSelectedWorkerId() { - return String(pageUiState.pageAiBoardWorkerId || pageAiBoardDefaultWorkerId()).trim() || pageAiBoardDefaultWorkerId(); - } - - function pageAiBoardSelectedWorkflowId() { - return String(pageUiState.pageAiBoardWorkflowId || pageAiBoardDefaultWorkflowId()).trim() || pageAiBoardDefaultWorkflowId(); - } - - function pageAiBoardModelOptions(workerId) { - var selectedWorkerId = String(workerId || pageAiBoardSelectedWorkerId()).trim() || pageAiBoardDefaultWorkerId(); - var worker = pageAiBoardWorkerOptions().find(function(item) { return String(item.id || '') === selectedWorkerId; }) || {}; - var options = pageAiNormalizeArray(worker.modelOptions || worker.models || worker.allowedModels) - .map(function(option) { - if (typeof option === 'string') return { id: option, label: option }; - return { - id: String(option && (option.id || option.value || option.modelOverride || option.model) || '').trim(), - label: String(option && (option.label || option.name || option.id || option.value || option.modelOverride || option.model) || '').trim(), - default: Boolean(option && option.default) - }; - }) - .filter(function(option) { return option.id; }); - if (!options.length && selectedWorkerId === pageAiBoardDefaultWorkerId()) options = pageAiBoardDefaultModelOptions(); - return options; - } - - function pageAiBoardSelectedModelOverride() { - var options = pageAiBoardModelOptions(); - var fallback = (options.find(function(option) { return option.default; }) || options[0] || { id: pageAiBoardDefaultModelOverride() }).id; - var selected = String(pageUiState.pageAiBoardModelOverride || fallback || pageAiBoardDefaultModelOverride()).trim(); - if (!options.length) return selected || pageAiBoardDefaultModelOverride(); - return options.some(function(option) { return option.id === selected; }) ? selected : fallback; - } - - function pageAiBoardModelLabel(modelOverride) { - var id = String(modelOverride || pageAiBoardSelectedModelOverride()).trim(); - var option = pageAiBoardModelOptions().find(function(item) { return item.id === id; }); - return String(option && (option.label || option.id) || id || '默认').trim(); - } - - function pageAiBoardWorkerOptions() { - var byId = {}; - var fallback = { - id: pageAiBoardDefaultWorkerId(), - name: 'MNote 页面 AI · ZCode', - surface: 'mnote-page-ai', - role: 'developer', - agentType: 'zcode', - agentModel: 'default', - capabilities: ['text', 'repo-edit', 'terminal', 'mnote-capability-envelope', 'local-markdown-edit'], - modelOptions: pageAiBoardDefaultModelOptions() - }; - pageAiNormalizeArray(pageUiState.pageAiBoardWorkers).concat([fallback]).forEach(function(worker) { - var id = String(worker && (worker.id || worker.workerPresetId || worker.presetId) || '').trim(); - var surface = String(worker && (worker.surface || worker.productSurface) || '').trim(); - if (!id || id !== pageAiBoardDefaultWorkerId()) return; - if (surface && surface !== 'mnote-page-ai') return; - byId[id] = Object.assign({}, fallback, worker, { id: id, surface: 'mnote-page-ai' }); - }); - return Object.keys(byId).map(function(id) { return byId[id]; }); - } - - function pageAiBoardWorkflowOptions() { - var byId = {}; - var fallback = { id: pageAiBoardDefaultWorkflowId(), name: 'MNote 页面 AI', surface: 'mnote-page-ai' }; - pageAiNormalizeArray(pageUiState.pageAiBoardWorkflows).concat([fallback]).forEach(function(workflow) { - var id = String(workflow && (workflow.id || workflow.workflowId) || '').trim(); - var surface = String(workflow && (workflow.surface || workflow.productSurface) || '').trim(); - if (!id || id !== pageAiBoardDefaultWorkflowId()) return; - if (surface && surface !== 'mnote-page-ai') return; - byId[id] = Object.assign({}, fallback, workflow, { id: id, surface: 'mnote-page-ai' }); - }); - return Object.keys(byId).map(function(id) { return byId[id]; }); - } - - function pageAiBoardWorkerLabel(workerId) { - var id = String(workerId || pageAiBoardSelectedWorkerId()).trim(); - var worker = pageAiBoardWorkerOptions().find(function(item) { return String(item.id || '') === id; }); - return String(worker && (worker.name || worker.id) || id || 'Board worker').trim(); - } - - function pageAiBoardWorkflowLabel(workflowId) { - var id = String(workflowId || pageAiBoardSelectedWorkflowId()).trim(); - var workflow = pageAiBoardWorkflowOptions().find(function(item) { return String(item.id || '') === id; }); - return String(workflow && (workflow.name || workflow.id) || id || 'Board workflow').trim(); - } - - async function pageAiLoadBoardCatalog() { - if (!pageAiBoardFirstEnabled()) return; - try { - var workersResponse = await fetch('/api/page-ai/board/workers', { headers: { accept: 'application/json' }, cache: 'no-store' }); - var workersPayload = await workersResponse.json().catch(function() { return null; }); - if (workersResponse.ok) { - var workers = pageAiNormalizeArray(workersPayload && workersPayload.workers); - if (!workers.length) workers = pageAiNormalizeArray(workersPayload && workersPayload.workers && workersPayload.workers.workers); - pageUiState.pageAiBoardWorkers = workers; - } - var workflowsResponse = await fetch('/api/page-ai/board/workflows', { headers: { accept: 'application/json' }, cache: 'no-store' }); - var workflowsPayload = await workflowsResponse.json().catch(function() { return null; }); - if (workflowsResponse.ok) { - var workflows = pageAiNormalizeArray(workflowsPayload && workflowsPayload.workflows); - pageUiState.pageAiBoardWorkflows = workflows; - } - pageUiState.pageAiBoardCatalogError = ''; - } catch (error) { - pageUiState.pageAiBoardCatalogError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - } - - function pageAiExtractFinalAnswer(summary) { - var text = String(summary || '').trim(); - if (!text) return ''; - var match = text.match(/(?:^|\n)##\s*FINAL_ANSWER\s*\n([\s\S]*?)(?=\n##\s+|$)/i); - if (match && match[1].trim()) return match[1].trim(); - return text - .replace(/^##\s*Completed\s*/i, '') - .replace(/\n##\s*Comments[\s\S]*$/i, '') - .replace(/\n##\s*Remaining[\s\S]*$/i, '') - .trim(); - } - - function pageAiFileUriToPath(rootUri) { - var value = String(rootUri || '').trim(); - if (!value.indexOf('file://') === 0 && value.slice(0, 7) !== 'file://') return ''; - try { - return decodeURIComponent(value.replace(/^file:\/\//, '')); - } catch (_error) { - return value.replace(/^file:\/\//, ''); - } - } - - function pageAiJoinPath(rootPath, relativePath) { - var root = String(rootPath || '').replace(/\/+$/, ''); - var rel = String(relativePath || '').replace(/\\/g, '/').replace(/^\/+/, ''); - if (!root || !rel) return root || rel; - return root + '/' + rel; - } - - function pageAiBoardCapabilities() { - return [ - 'mnote.current_page.read', - 'mnote.selection.read', - 'mnote.open_resources.snapshot', - 'mnote.allowed_roots.describe', - 'mnote.weknora.search', - 'mnote.reference.open', - 'mnote.page_aggregate.snapshot', - 'mnote.local_file.receipt' - ]; - } - - function pageAiBuildBoardEnvelope(prompt, scopedContext, runTargetSnapshot, contextRefs, allowedRoots, agentTargetPackage) { - var workspacePath = agentTargetPackage && agentTargetPackage.workspacePath && typeof agentTargetPackage.workspacePath === 'object' - ? agentTargetPackage.workspacePath - : (runTargetSnapshot && runTargetSnapshot.workspacePath && typeof runTargetSnapshot.workspacePath === 'object' ? runTargetSnapshot.workspacePath : {}); - var relativePath = String(workspacePath.relativePath || agentTargetPackage && agentTargetPackage.relativePath || runTargetSnapshot && runTargetSnapshot.relativePath || '').trim(); - var rootPath = pageAiFileUriToPath(currentRootUri()); - var absolutePath = String(workspacePath.absolutePath || workspacePath.path || '').trim() || pageAiJoinPath(rootPath, relativePath); - var selectedText = String(scopedContext && scopedContext.selectedText || '').trim(); - var writeIntent = /\b(edit|write|append|replace|fix|rewrite|update)\b|编辑|修改|追加|替换|修复|改写|更新/.test(String(prompt || '').toLowerCase()); - return { - schema: 'mnote.page_ai.board_task.v1', - intent: writeIntent ? 'edit' : 'ask', - message: prompt, - workspaceId: resolveWorkspaceId(document.body), - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - documentId: currentDocumentId(), - pageTitle: String((currentPageAggregate().meta || {}).title || document.title || '').trim(), - board: { - workflowId: pageAiBoardSelectedWorkflowId(), - workflowName: pageAiBoardWorkflowLabel(), - workerPresetId: pageAiBoardSelectedWorkerId(), - workerName: pageAiBoardWorkerLabel(), - modelOverride: pageAiBoardSelectedModelOverride(), - modelLabel: pageAiBoardModelLabel() - }, - primaryTarget: { - kind: 'markdown_file', - relativePath: relativePath, - absolutePath: absolutePath, - documentId: currentDocumentId(), - workspacePath: workspacePath - }, - selection: { - text: selectedText, - range: null - }, - contextRefs: contextRefs, - allowedRoots: allowedRoots, - capabilities: pageAiBoardCapabilities(), - modelOverride: pageAiBoardSelectedModelOverride(), - pageContext: pageAiPageContextForRefs(scopedContext.pageContext, contextRefs), - runTargetSnapshot: runTargetSnapshot, - targetPackage: agentTargetPackage, - writePolicy: { - allowFileWrite: allowedRoots.some(function(root) { return String(root.permission || '').indexOf('write') >= 0; }), - requireHumanApproval: false, - forbidMnoteInternalStateWrite: true - } - }; - } - - function pageAiBoardReceiptSummary(payload) { - var receipt = payload && payload.receipt && typeof payload.receipt === 'object' ? payload.receipt : null; - var board = payload && payload.board && typeof payload.board === 'object' ? payload.board : null; - if (receipt && String(receipt.summary || '').trim()) return String(receipt.summary || '').trim(); - var nodeRuns = Array.isArray(board && board.nodeRuns) ? board.nodeRuns : []; - for (var i = nodeRuns.length - 1; i >= 0; i -= 1) { - if (String(nodeRuns[i] && nodeRuns[i].outputSummary || '').trim()) return String(nodeRuns[i].outputSummary).trim(); - } - var events = Array.isArray(board && board.events) ? board.events : []; - if (events.length) return String(events[events.length - 1].message || '').trim(); - return ''; - } - - function pageAiBoardRunStatus(payload) { - var board = payload && payload.board && typeof payload.board === 'object' ? payload.board : null; - var run = board && board.run && typeof board.run === 'object' ? board.run : (board && board.id ? board : null); - return String(run && run.status || '').trim(); - } - - function pageAiBoardRunId(payload) { - var board = payload && payload.board && typeof payload.board === 'object' ? payload.board : null; - var run = board && board.run && typeof board.run === 'object' ? board.run : (board && board.id ? board : null); - return String(run && run.id || payload && payload.runId || payload && payload.id || '').trim(); - } - - function pageAiBoardReceiptObject(payload) { - var receipt = payload && payload.receipt && typeof payload.receipt === 'object' ? payload.receipt : null; - if (receipt && receipt.receipt && typeof receipt.receipt === 'object') return receipt.receipt; - return receipt; - } - - function pageAiBoardFinalAnswer(payload) { - var receipt = pageAiBoardReceiptObject(payload); - var candidates = [ - receipt && receipt.finalAnswer, - receipt && receipt.summary, - receipt && receipt.outputSummary, - pageAiBoardReceiptSummary(payload) - ]; - for (var i = 0; i < candidates.length; i += 1) { - var text = pageAiExtractFinalAnswer(candidates[i]); - if (text) return text; - } - return ''; - } - - function pageAiBoardChangedFiles(payload) { - var receipt = pageAiBoardReceiptObject(payload) || {}; - var changed = pageAiNormalizeArray(receipt.changedFiles || receipt.changed_files || receipt.filesChanged); - return changed.map(function(file) { - if (typeof file === 'string') return { path: file }; - return { - path: String(file && (file.path || file.absolutePath || file.relativePath || file.file) || '').trim(), - status: String(file && (file.status || file.changeType || file.kind) || '').trim() - }; - }).filter(function(file) { return file.path; }); - } - - function pageAiBoardTimeline(payload) { - var board = payload && payload.board && typeof payload.board === 'object' ? payload.board : null; - var events = pageAiNormalizeArray(board && board.events); - if (!events.length && board && board.run && Array.isArray(board.run.events)) events = board.run.events; - return pageAiNormalizeArray(events).map(function(event) { - var timestamp = String(event && (event.timestamp || event.createdAt || event.created_at || event.time) || '').trim(); - var type = String(event && (event.type || event.eventType || event.event || event.status) || '').trim(); - var message = String(event && (event.message || event.summary || event.title || '') || '').trim(); - return { timestamp: timestamp, type: type, message: message }; - }).filter(function(event) { return event.type || event.message; }); - } - - function pageAiBoardVerification(payload) { - var receipt = pageAiBoardReceiptObject(payload) || {}; - return pageAiNormalizeArray(receipt.verification || receipt.checks).map(function(item) { - if (typeof item === 'string') return { command: item, status: '' }; - return { - command: String(item && (item.command || item.name || item.label) || '').trim(), - status: String(item && (item.status || item.result) || '').trim(), - output: String(item && (item.output || item.summary) || '').trim() - }; - }).filter(function(item) { return item.command || item.status || item.output; }); - } - - function pageAiBuildBoardRunDetail(runId, payload) { - var status = pageAiBoardRunStatus(payload); - return { - schema: 'mnote.page_ai.board_run_detail.v1', - runId: runId, - status: status === 'complete' ? 'completed' : (status || 'completed'), - workflowId: pageAiBoardSelectedWorkflowId(), - workflowName: pageAiBoardWorkflowLabel(), - workerPresetId: pageAiBoardSelectedWorkerId(), - workerName: pageAiBoardWorkerLabel(), - modelOverride: pageAiBoardSelectedModelOverride(), - modelLabel: pageAiBoardModelLabel(), - finalAnswer: pageAiBoardFinalAnswer(payload), - changedFiles: pageAiBoardChangedFiles(payload), - verification: pageAiBoardVerification(payload), - timeline: pageAiBoardTimeline(payload), - receipt: pageAiBoardReceiptObject(payload) || null, - payload: payload || null, - updatedAt: Date.now() - }; - } - - function pageAiStoreBoardRunDetail(runId, payload) { - if (!runId) return null; - if (!pageUiState.pageAiBoardRunDetails || typeof pageUiState.pageAiBoardRunDetails !== 'object') { - pageUiState.pageAiBoardRunDetails = {}; - } - var detail = pageAiBuildBoardRunDetail(runId, payload); - pageUiState.pageAiBoardRunDetails[runId] = detail; - var current = pageAiCurrentSession(); - if (current) { - current.boardRuns = Object.assign({}, current.boardRuns || {}, { [runId]: detail }); - current.boardRunId = runId; - current.workflowId = detail.workflowId; - current.workerPresetId = detail.workerPresetId; - current.modelOverride = detail.modelOverride; - current.receiptId = String(detail.receipt && (detail.receipt.id || detail.receipt.receiptId) || runId || '').trim(); - } - return detail; - } - - async function pageAiPollBoardRun(runId) { - var lastPayload = null; - for (var attempt = 0; attempt < 360; attempt += 1) { - var response = await fetch('/api/page-ai/board/runs/' + encodeURIComponent(runId), { headers: { accept: 'application/json' }, cache: 'no-store' }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'board_run_status_failed_' + response.status)); - lastPayload = payload; - var status = pageAiBoardRunStatus(payload); - if (status) pageAiSetRunStatus(status === 'complete' ? 'completed' : status); - renderPageAiControls(); - if (['complete', 'failed', 'blocked', 'cancelled'].indexOf(status) >= 0) return payload; - await new Promise(function(resolve) { window.setTimeout(resolve, attempt < 8 ? 1000 : 2500); }); - } - return lastPayload; - } - - async function sendPageAiBoardMessage(text) { - if (pageUiState.pageAiBusy) return; - var prompt = searchText(text); - if (!prompt) return; - pageUiState.pageAiBusy = true; - pageAiSetRunStatus('preparing'); - var currentSession = pageAiCurrentSession(); - try { - var contextSnapshot = currentPageAiContextSnapshot(); - var scopedContext = pageAiScopedPageContext(contextSnapshot); - scopedContext.editorTarget = currentPageAiScopedEditorTarget(); - assertPageAiTargetInCurrentWorkspace(scopedContext.editorTarget); - if (currentSourceKind() === 'local_folder' && !pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).length) { - await pageAiLoadAllowedRoots(); - } - await assertPageAiTargetWritable(scopedContext.editorTarget); - var runTargetSnapshot = pageAiBuildRunTargetSnapshot(scopedContext, prompt); - var contextRefs = pageAiBuildContextRefs(scopedContext, runTargetSnapshot); - var allowedRoots = pageAiBuildAllowedRoots(); - var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot); - assertPageAiLocalWritePermission(prompt, agentTargetPackage); - var envelope = pageAiBuildBoardEnvelope(prompt, scopedContext, runTargetSnapshot, contextRefs, allowedRoots, agentTargetPackage); - pageUiState.pageAiMessages.push({ role: 'user', content: prompt }); - pageUiState.pageAiMessages.push({ role: 'assistant', kind: 'status', content: '正在处理…', streaming: true }); - if (currentSession) { - if (currentSession.title === '新会话') currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt; - currentSession.schema = 'mnote.page_ai.session.v2'; - currentSession.source = 'board'; - currentSession.workerPresetId = pageAiBoardSelectedWorkerId(); - currentSession.workflowId = pageAiBoardSelectedWorkflowId(); - currentSession.modelOverride = pageAiBoardSelectedModelOverride(); - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - var response = await fetch('/api/page-ai/board/runs', { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - body: JSON.stringify({ - workflowId: pageAiBoardSelectedWorkflowId(), - workerPresetId: pageAiBoardSelectedWorkerId(), - modelOverride: pageAiBoardSelectedModelOverride(), - message: prompt, - envelope: envelope, - autoRun: true, - priority: 'high' - }) - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'board_run_failed_' + response.status)); - var runId = pageAiBoardRunId(payload); - pageUiState.pageAiCurrentRunId = runId; - if (runId) { - pageUiState.pageAiMessages = pageUiState.pageAiMessages.filter(function(message) { return !(message && message.kind === 'status' && message.streaming); }); - pageUiState.pageAiMessages.push({ role: 'assistant', kind: 'status', content: 'Agent Board 正在处理…', streaming: true, boardRunId: runId }); - } - pageAiSetRunStatus('running'); - renderPageAiConversation(); - pageAiApplyBoardFirstShell(); - var finalPayload = runId ? await pageAiPollBoardRun(runId) : payload; - var detail = pageAiStoreBoardRunDetail(runId, finalPayload); - var finalAnswer = detail && detail.finalAnswer || 'Agent Board run 已结束,但没有返回 final answer。'; - pageUiState.pageAiMessages = pageUiState.pageAiMessages.filter(function(message) { return !(message && message.kind === 'status' && message.streaming); }); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: finalAnswer, - boardRunId: runId, - workflowId: pageAiBoardSelectedWorkflowId(), - workerPresetId: pageAiBoardSelectedWorkerId(), - modelOverride: pageAiBoardSelectedModelOverride(), - retryPrompt: prompt, - receiptId: String(detail && detail.receipt && (detail.receipt.id || detail.receipt.receiptId) || runId || '').trim() - }); - if (currentSession) { - currentSession.schema = 'mnote.page_ai.session.v2'; - currentSession.source = 'board'; - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - currentSession.boardRunId = runId; - currentSession.workflowId = pageAiBoardSelectedWorkflowId(); - currentSession.workerPresetId = pageAiBoardSelectedWorkerId(); - currentSession.modelOverride = pageAiBoardSelectedModelOverride(); - } - pageAiPersistSessions(); - pageAiSetRunStatus(pageAiBoardRunStatus(finalPayload) === 'complete' ? 'completed' : pageAiBoardRunStatus(finalPayload) || 'completed'); - renderPageAiConversation(); - pageAiApplyBoardFirstShell(); - } catch (error) { - pageAiSetRunStatus('failed'); - pageUiState.pageAiMessages.push({ role: 'assistant', content: 'Agent Board 处理失败:' + (error instanceof Error ? error.message : String(error)) }); - renderPageAiConversation(); - pageAiApplyBoardFirstShell(); - } finally { - pageUiState.pageAiBusy = false; - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - } - } - - function pageAiApplyAgentDescriptorsPayload(payload) { - var descriptors = pageAiNormalizeArray(payload && payload.descriptors) - .map(pageAiNormalizeAgentDescriptor) - .filter(function(descriptor) { return descriptor.schema === PAGE_AI_DESCRIPTOR_SCHEMA; }); - if (!descriptors.length) return false; - pageUiState.pageAiAgentDescriptors = descriptors; - pageUiState.pageAiAgentDescriptorsLoaded = true; - pageUiState.pageAiAgentDescriptorError = ''; - if (payload && payload.preferenceValues && typeof payload.preferenceValues === 'object') { - pageUiState.pageAiSkillPreferences = Object.assign( - {}, - pageAiDescriptorPreferenceValues(), - payload.preferenceValues - ); - } - var current = pageAiCurrentAgentId(); - var record = pageAiAgentRecord(current); - pageUiState.pageAiAgentId = record.id; - pageUiState.pageAiAcpRuntime = record.acpRuntime || pageUiState.pageAiAcpRuntime || 'reasonix'; - if (record.tools) pageUiState.pageAiTools = record.tools; - return true; - } - - async function pageAiLoadAgentDescriptors() { - try { - var url = new URL('/api/page-ai/agents/descriptors', window.location.origin); - url.searchParams.set('profile', pageAiCurrentProfile()); - var response = await fetch(url.toString(), { - headers: { accept: 'application/json' }, - cache: 'no-store' - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'agent_descriptors_failed_' + response.status)); - if (!pageAiApplyAgentDescriptorsPayload(payload)) throw new Error('agent_descriptors_empty'); - } catch (error) { - pageUiState.pageAiAgentDescriptorError = error instanceof Error ? error.message : String(error); - pageUiState.pageAiAgentDescriptorsLoaded = false; - } - renderPageAiProviderButtons(); - renderPageAiControls(); - return pageUiState.pageAiAgentDescriptors; - } - - async function pageAiLoadAiPreferences() { - try { - var url = new URL('/api/ui/preferences/effective', window.location.origin); - url.searchParams.set('workspaceId', resolveWorkspaceId(document.body)); - url.searchParams.set('sourceKind', currentSourceKind()); - url.searchParams.set('rootUri', currentRootUri()); - url.searchParams.set('documentId', currentDocumentId()); - var response = await fetch(url.toString(), { headers: { accept: 'application/json' }, cache: 'no-store' }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok || !payload || !payload.result) return; - var preferences = payload.result.aiPreferences && typeof payload.result.aiPreferences === 'object' - ? payload.result.aiPreferences - : {}; - pageUiState.pageAiSkillPreferences = Object.assign({}, preferences); - var defaultAgent = String(preferences['ai.common.default_agent_id'] || '').trim(); - if (defaultAgent) { - var agent = pageAiAgentRecord(defaultAgent); - pageUiState.pageAiAgentId = agent.id; - pageUiState.pageAiAcpRuntime = agent.acpRuntime || pageUiState.pageAiAcpRuntime || 'reasonix'; - if (agent.tools) pageUiState.pageAiTools = agent.tools; - } - var hermesProfile = String(preferences['ai.agent.hermes.profile_id'] || '').trim(); - if (hermesProfile) pageAiSetActiveProfile(hermesProfile); - var skillSource = String(preferences['ai.common.skills.active_source'] || '').trim(); - if (skillSource) pageUiState.pageAiActiveSkillSource = skillSource; - var selected = preferences['ai.common.context_refs.default_selected']; - if (selected && typeof selected === 'object' && !Array.isArray(selected)) { - pageUiState.pageAiSelectedContextRefs = Object.assign({}, pageAiEnsureContextRefState(), selected); - } - var collapsedSkillGroups = preferences['ai.common.skills.groups.collapsed']; - if (collapsedSkillGroups && typeof collapsedSkillGroups === 'object' && !Array.isArray(collapsedSkillGroups)) { - pageUiState.pageAiCollapsedSkillGroups = Object.assign({}, collapsedSkillGroups); - } - } catch (_) {} - renderPageAiControls(); - } - - function pageAiNormalizeAccessGrant(grant) { - if (!grant || typeof grant !== 'object') return null; - var rootUri = String(grant.rootUri || grant.root_uri || '').trim(); - if (!rootUri) return null; - var permission = String(grant.permission || grant.access || '').trim() || 'read'; - return { - id: String(grant.id || '').trim(), - rootUri: rootUri, - rootPath: String(grant.rootPath || grant.root_path || '').trim(), - permission: permission === 'read_write' ? 'write' : permission, - recursive: grant.recursive !== false, - source: String(grant.source || 'sqlite_directory_grant').trim() || 'sqlite_directory_grant', - status: String(grant.status || 'active').trim() || 'active' - }; - } - - async function pageAiLoadAllowedRoots() { - if (currentSourceKind() !== 'local_folder') { - pageUiState.pageAiAllowedRoots = []; - renderPageAiControls(); - return []; - } - try { - var response = await fetch('/api/user/access-policy', { - headers: { accept: 'application/json' }, - cache: 'no-store' - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'access_policy_failed_' + response.status)); - var grants = pageAiNormalizeArray(payload && (payload.grants || payload.policy && payload.policy.grants)); - var currentRoot = String(currentRootUri() || '').trim(); - pageUiState.pageAiAllowedRoots = grants.map(pageAiNormalizeAccessGrant).filter(Boolean).filter(function(grant) { - return grant.status === 'active' && (!currentRoot || grant.rootUri === currentRoot); - }); - pageUiState.pageAiAllowedRootsError = ''; - } catch (error) { - pageUiState.pageAiAllowedRoots = []; - pageUiState.pageAiAllowedRootsError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - return pageUiState.pageAiAllowedRoots; - } + const { pageUiState } = context || {}; function updatePageAiTriggerState() { var trigger = document.querySelector('[data-testid="wolai-floating-ai"]'); if (!(trigger instanceof HTMLElement)) return; - trigger.setAttribute('data-state', pageUiState.pageAiOpen ? 'open' : 'closed'); - trigger.setAttribute('aria-expanded', pageUiState.pageAiOpen ? 'true' : 'false'); + var open = !!(pageUiState && pageUiState.pageAiOpen); + trigger.setAttribute('data-state', open ? 'open' : 'closed'); + trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); } - - - function pageAiOpenLocation(loc) { - var path = String(loc || '').trim(); - if (!path) return; - openLocalResourceInActiveTab({ path: path }).then(function(opened) { - if (!opened) { - var href = buildLocalFileOpenUrl(path, false); - if (href) window.open(href, '_blank', 'noopener,noreferrer'); - } - }); - } - - function pageAiEvidenceRangeFromParam(value) { - var text = String(value || '').trim(); - if (!text) return null; - var match = text.match(/^(\d+)[-:,](\d+)$/); - if (!match) return text; - return { start: Number(match[1]), end: Number(match[2]) }; - } - - function pageAiEvidenceBboxFromParam(value) { - var text = String(value || '').trim(); - if (!text) return null; - var parts = text.split(',').map(function(part) { return Number(part.trim()); }); - if (parts.length < 4 || parts.slice(0, 4).some(function(part) { return !Number.isFinite(part); })) return text; - return { x0: parts[0], y0: parts[1], x1: parts[2], y1: parts[3] }; - } - - function pageAiDocumentIdFromUrl(url) { - var prefix = '/documents/'; - if (!url.pathname.startsWith(prefix)) return ''; - try { - var raw = url.pathname.slice(prefix.length).split('/')[0] || ''; - var decoded = decodeURIComponent(raw); - if (decoded.indexOf('%') >= 0 || decoded.indexOf('\uFFFD') >= 0) return ''; - return decoded; - } catch (_error) { - return ''; - } - } - - function pageAiCitationResourcePath(url, rootUri) { - var explicitPath = String(url.searchParams.get('resourcePath') || '').trim(); - if (explicitPath) return explicitPath; - var raw = String(url.searchParams.get('resourceTab') || '').trim(); - if (!raw) { - try { - var decodedHash = decodeURIComponent(String(url.hash || '')); - var marker = '#resource-tab-'; - var markerIndex = decodedHash.indexOf(marker); - if (markerIndex >= 0) raw = decodedHash.slice(markerIndex + marker.length).replace(/^#+/, '').trim(); - } catch (_error) {} - } - if (raw.indexOf('::') >= 0) raw = raw.slice(raw.indexOf('::') + 2); - if (!raw.startsWith('resource:file:')) return ''; - var rest = raw.slice('resource:file:'.length); - var prefix = String(rootUri || '').trim() + ':'; - if (!prefix.trim() || !rest.startsWith(prefix)) return ''; - return rest.slice(prefix.length).replace(/^\/+/, ''); - } - - function pageAiSameMnoteOrigin(url) { - try { - var current = new URL(window.location.origin); - if (url.origin === current.origin) return true; - if (url.hostname === 'mnote.local') return true; - var localNames = ['localhost', '127.0.0.1', '::1', 'mnote.local']; - function defaultPort(protocol) { - return protocol === 'https:' ? '443' : protocol === 'http:' ? '80' : ''; - } - return localNames.indexOf(url.hostname) >= 0 && - localNames.indexOf(current.hostname) >= 0 && - String(url.port || defaultPort(url.protocol)) === String(current.port || defaultPort(current.protocol)); - } catch (_error) { - return false; - } - } - - function pageAiPortableCitationUrl(url) { - try { - if (url.pathname === '/api/local-folder/files/open') { - return Boolean(String(url.searchParams.get('rootUri') || '').trim()) && - Boolean(String(url.searchParams.get('path') || '').trim()); - } - if (!url.pathname.startsWith('/documents/')) return false; - var resourceTab = String(url.searchParams.get('resourceTab') || '').trim(); - return String(url.searchParams.get('sourceKind') || '').trim() === 'local_folder' || - Boolean(String(url.searchParams.get('rootUri') || '').trim()) || - Boolean(String(url.searchParams.get('resourcePath') || '').trim()) || - resourceTab.startsWith('resource:file:'); - } catch (_error) { - return false; - } - } - - function pageAiOpenCitationUrl(href) { - var url; - try { - url = new URL(String(href || ''), window.location.origin); - } catch (_error) { - return false; - } - var unwrappedHref = pageAiUnwrapSearchCitationUrl(url); - if (unwrappedHref) return pageAiOpenCitationUrl(unwrappedHref); - if (!pageAiSameMnoteOrigin(url) && !pageAiPortableCitationUrl(url)) return false; - if (url.pathname === '/api/local-folder/files/open') { - var fileRootUri = String(url.searchParams.get('rootUri') || currentRootUri() || '').trim(); - var filePath = String(url.searchParams.get('path') || '').trim(); - if (!filePath) return false; - void openLocalResourceInActiveTab({ - path: filePath, - rootUri: fileRootUri, - documentId: currentDocumentId() || '', - workspaceId: resolveWorkspaceId(document.body) || '', - sourceKind: currentSourceKind() || 'local_folder', - page: url.searchParams.get('page') || undefined, - bbox: pageAiEvidenceBboxFromParam(url.searchParams.get('bbox')), - sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(), - blockId: String(url.searchParams.get('blockId') || '').trim(), - evidenceText: String(url.searchParams.get('evidenceText') || '').trim(), - lineRange: pageAiEvidenceRangeFromParam(url.searchParams.get('lineRange')), - charRange: pageAiEvidenceRangeFromParam(url.searchParams.get('charRange')), - openTarget: 'active-tab', - paneRole: 'primary' - }).then(function(opened) { - if (!opened) window.open(url.toString(), '_blank', 'noopener,noreferrer'); - }); - return true; - } - if (!url.pathname.startsWith('/documents/')) return false; - var rootUri = String(url.searchParams.get('rootUri') || currentRootUri() || '').trim(); - var resourcePath = pageAiCitationResourcePath(url, rootUri); - var documentId = pageAiDocumentIdFromUrl(url) || currentDocumentId() || ''; - var workspaceId = String(url.searchParams.get('workspaceId') || resolveWorkspaceId(document.body) || '').trim(); - if (resourcePath) { - void openLocalResourceInActiveTab({ - path: resourcePath, - rootUri: rootUri, - documentId: documentId, - workspaceId: workspaceId, - sourceKind: String(url.searchParams.get('sourceKind') || currentSourceKind() || 'local_folder').trim(), - page: url.searchParams.get('page') || undefined, - bbox: pageAiEvidenceBboxFromParam(url.searchParams.get('bbox')), - sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(), - blockId: String(url.searchParams.get('blockId') || '').trim(), - evidenceText: String(url.searchParams.get('evidenceText') || '').trim(), - lineRange: pageAiEvidenceRangeFromParam(url.searchParams.get('lineRange')), - charRange: pageAiEvidenceRangeFromParam(url.searchParams.get('charRange')), - openTarget: 'active-tab', - paneRole: 'primary' - }).then(function(opened) { - if (!opened) window.location.assign(url.pathname + url.search + url.hash); - }); - return true; - } - if (typeof window.__mnoteDocumentPaneRuntime?.openPrimaryDocument === 'function') { - void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({ - documentId: documentId, - workspaceId: workspaceId, - sourceKind: String(url.searchParams.get('sourceKind') || currentSourceKind() || '').trim(), - rootUri: rootUri, - url: url - }); - return true; - } - return false; - } - - function pageAiChatOnlyProfileEntries() { - var profiles = pageAiNormalizeArray(pageUiState.pageAiProfiles); - return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY.map(function(spec) { - var profile = profiles.find(function(candidate) { - var candidateSpec = pageAiChatOnlyProfileSpec(candidate); - return candidateSpec && candidateSpec.profileId === spec.profileId; - }); - return Object.assign({}, profile || { - profileId: spec.profileId, - name: spec.profileId, - alias: spec.label, - kind: 'shared', - baseProfile: spec.baseProfile, - providerKind: spec.providerKind || '', - readonly: true - }, { menuLabel: spec.label }); - }); - } - - function pageAiHermesProfileEntries() { - var profiles = pageAiNormalizeArray(pageUiState.pageAiProfiles).filter(function(profile) { - return !pageAiChatOnlyProfileSpec(profile); - }); - if (profiles.length) return profiles; - return [{ profileId: pageAiCurrentProfile(), name: pageAiCurrentProfile(), alias: pageAiCurrentProfile(), kind: 'personal' }]; - } - - - function pageAiNormalizeProfiles(payload) { - var upstream = pageAiUnwrapUpstream(payload); - var profiles = pageAiNormalizeArray(upstream && upstream.profiles ? upstream.profiles : upstream); - return profiles.map(function(profile) { - var profileId = String(profile && (profile.profileId || profile.id || profile.name) || '').trim(); - var kind = String(profile && profile.kind || profile && profile.profileKind || '').trim(); - var canManageSkills = profile && profile.canManageSkills !== false; - return { - profileId: profileId || pageAiProfileValue(profile) || 'default', - name: profileId || pageAiProfileValue(profile) || 'default', - active: Boolean(profile && profile.active), - model: String(profile && profile.model || '').trim(), - gateway: String(profile && profile.gateway || '').trim(), - alias: String(profile && (profile.displayName || profile.alias) || '').trim(), - kind: kind, - ownerUserId: String(profile && profile.ownerUserId || '').trim(), - baseProfile: String(profile && profile.baseProfile || '').trim(), - isolatedProfile: String(profile && profile.isolatedProfile || '').trim(), - providerKind: String(profile && profile.providerKind || profile && profile.provider_kind || '').trim(), - canRun: profile ? profile.canRun !== false : true, - canManageSkills: canManageSkills, - canManageConfig: profile ? profile.canManageConfig !== false : canManageSkills, - readonly: profile ? (profile.readonly === true || !canManageSkills) : false, - grantRole: String(profile && profile.grantRole || '').trim() - }; - }); - } - - function pageAiNormalizeSkills(payload) { - var upstream = pageAiUnwrapUpstream(payload); - var categories = pageAiNormalizeArray(upstream && upstream.categories ? upstream.categories : []); - var archived = pageAiNormalizeArray(upstream && upstream.archived ? upstream.archived : []); - return { - categories: categories.map(function(category) { - var skillRows = pageAiNormalizeArray(category && (category.skills || category.capabilities)); - return { - name: String(category && category.name || '').trim() || 'misc', - description: String(category && category.description || '').trim(), - title: String(category && category.title || '').trim(), - capabilities: pageAiNormalizeArray(category && category.capabilities), - skills: skillRows.map(function(skill) { - return { - name: String(skill && skill.name || '').trim(), - id: String(skill && skill.id || skill && skill.name || '').trim(), - title: String(skill && skill.title || skill && skill.name || skill && skill.id || '').trim(), - description: String(skill && skill.description || '').trim(), - enabled: skill && skill.enabled !== false, - toggleable: skill && skill.toggleable !== false, - source: String(skill && skill.source || 'local').trim() || 'local', - origin: String(skill && skill.origin || '').trim(), - createdBy: String(skill && skill.createdBy || '').trim(), - patchCount: Number(skill && skill.patchCount || 0), - modified: Boolean(skill && skill.modified), - builtin: Boolean(skill && skill.builtin), - configurable: skill && skill.configurable !== false, - configScope: String(skill && skill.configScope || '').trim(), - skillKind: String(skill && skill.skillKind || '').trim(), - profileId: String(skill && skill.profileId || '').trim(), - toolNames: pageAiNormalizeArray(skill && skill.toolNames), - tools: pageAiNormalizeArray(skill && skill.tools), - toolCount: Number(skill && skill.toolCount || 0), - disabledToolCount: Number(skill && skill.disabledToolCount || 0), - status: String(skill && skill.status || '').trim(), - capabilityId: String(skill && skill.capabilityId || '').trim(), - capabilityKind: String(skill && skill.capabilityKind || '').trim(), - uiKind: String(skill && skill.uiKind || '').trim(), - requiresContextRefs: pageAiNormalizeArray(skill && skill.requiresContextRefs), - readOnly: Boolean(skill && (skill.readOnly || skill.readonly || skill.configurable === false)), - category: String(skill && skill.category || category && category.name || '').trim(), - categoryTitle: String(skill && skill.categoryTitle || category && category.title || category && category.name || '').trim() - }; - }) - }; - }), - archived: archived.map(function(skill) { - return { - name: String(skill && skill.name || '').trim(), - id: String(skill && skill.id || skill && skill.name || '').trim(), - title: String(skill && skill.title || skill && skill.name || skill && skill.id || '').trim(), - description: String(skill && skill.description || '').trim(), - enabled: skill && skill.enabled !== false, - toggleable: skill && skill.toggleable !== false, - source: String(skill && skill.source || 'local').trim() || 'local', - origin: String(skill && skill.origin || '').trim(), - createdBy: String(skill && skill.createdBy || '').trim(), - patchCount: Number(skill && skill.patchCount || 0), - modified: Boolean(skill && skill.modified), - builtin: Boolean(skill && skill.builtin), - configurable: skill && skill.configurable !== false, - configScope: String(skill && skill.configScope || '').trim(), - skillKind: String(skill && skill.skillKind || '').trim(), - profileId: String(skill && skill.profileId || '').trim(), - toolNames: pageAiNormalizeArray(skill && skill.toolNames), - tools: pageAiNormalizeArray(skill && skill.tools), - toolCount: Number(skill && skill.toolCount || 0), - disabledToolCount: Number(skill && skill.disabledToolCount || 0), - status: String(skill && skill.status || '').trim(), - capabilityId: String(skill && skill.capabilityId || '').trim(), - capabilityKind: String(skill && skill.capabilityKind || '').trim(), - uiKind: String(skill && skill.uiKind || '').trim(), - requiresContextRefs: pageAiNormalizeArray(skill && skill.requiresContextRefs), - categoryTitle: String(skill && skill.categoryTitle || '').trim(), - readOnly: Boolean(skill && (skill.readOnly || skill.readonly || skill.configurable === false)) - }; - }) - }; - } - - function pageAiSkillListEntries() { - var result = []; - pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { - pageAiNormalizeArray(category.skills).forEach(function(skill) { - result.push({ - category: category.name, - name: skill.name, - id: skill.id || skill.name, - title: skill.title || skill.name, - description: skill.description, - enabled: skill.enabled !== false, - toggleable: skill.toggleable !== false, - source: skill.source || 'local', - origin: skill.origin || '', - createdBy: skill.createdBy || '', - patchCount: Number(skill.patchCount || 0), - modified: Boolean(skill.modified), - builtin: Boolean(skill.builtin), - configurable: skill.configurable !== false, - configScope: skill.configScope || '', - skillKind: skill.skillKind || '', - profileId: skill.profileId || '', - toolNames: skill.toolNames || [], - tools: skill.tools || [], - toolCount: Number(skill.toolCount || 0), - disabledToolCount: Number(skill.disabledToolCount || 0), - status: skill.status || '', - capabilityId: skill.capabilityId || '', - capabilityKind: skill.capabilityKind || '', - uiKind: skill.uiKind || '', - requiresContextRefs: skill.requiresContextRefs || [], - categoryTitle: skill.categoryTitle || category.title || category.name || '', - readOnly: Boolean(skill.readOnly || skill.readonly || skill.configurable === false) - }); - }); - }); - return result.concat(pageAiNormalizeArray(pageUiState.pageAiSkills.archived)); - } - - function pageAiSetActiveProfile(profileName) { - var next = String(profileName || '').trim() || 'mnoteai'; - pageUiState.pageAiActiveProfileName = next; - document.documentElement.setAttribute('data-mnote-page-ai-profile', next); - document.documentElement.setAttribute('data-mnote-page-ai-tool-model', pageAiMnoteToolModel()); - } - - function pageAiSetRunStatus(status, runId) { - pageUiState.pageAiRunStatus = status || 'idle'; - pageUiState.pageAiCurrentRunId = runId || pageUiState.pageAiCurrentRunId || ''; - document.documentElement.setAttribute('data-mnote-page-ai-run-status', pageUiState.pageAiRunStatus); - if (pageUiState.pageAiCurrentRunId) { - document.documentElement.setAttribute('data-mnote-page-ai-run-id', pageUiState.pageAiCurrentRunId); - } - pageAiUpdateActiveRunSnapshotFromStatus(pageUiState.pageAiRunStatus, pageUiState.pageAiCurrentRunId); - } - - function pageAiApplyRuntimeState(runtime) { - if (!runtime || typeof runtime !== 'object') return; - var status = String(runtime.status || '').trim(); - var runId = String(runtime.runId || runtime.run_id || '').trim(); - if (status) pageAiSetRunStatus(status, runId); - var queueLength = Number(runtime.queueLength || runtime.queue_length || 0); - if (Number.isFinite(queueLength)) pageUiState.pageAiQueueLength = Math.max(0, queueLength); - var toolName = String(runtime.lastToolName || runtime.last_tool_name || '').trim(); - if (toolName) { - pageUiState.pageAiLastToolCall = { - event: String(runtime.lastEvent || runtime.last_event || ''), - name: toolName, - runId: runId, - traceId: String(runtime.traceId || runtime.trace_id || ''), - auditId: String(runtime.lastAuditId || runtime.last_audit_id || '') - }; - } - } - - function pageAiApplyRuntimeStatus(payload) { - if (!payload || typeof payload !== 'object') return null; - pageUiState.pageAiRuntimeStatus = payload; - pageUiState.pageAiRuntimeStatusError = ''; - var activeRun = payload.activeRun && typeof payload.activeRun === 'object' ? payload.activeRun : null; - if (activeRun && activeRun.hostRunId) { - pageAiSetRunStatus(String(activeRun.status || 'running'), String(activeRun.hostRunId || '')); - } else if (pageAiRunStatusIsTerminal(pageUiState.pageAiRunStatus)) { - pageAiUpdateActiveRunSnapshotFromStatus(pageUiState.pageAiRunStatus, pageUiState.pageAiCurrentRunId); - } else if (!activeRun && ['running', 'tool_calling', 'queued', 'pending', 'acp_pending'].indexOf(pageUiState.pageAiRunStatus) >= 0) { - pageAiSetRunStatus('idle', pageUiState.pageAiCurrentRunId); - } - var queue = payload.session && payload.session.queue && typeof payload.session.queue === 'object' ? payload.session.queue : null; - if (queue) { - var queuedCount = Number(queue.queuedCount || 0); - pageUiState.pageAiQueueLength = Number.isFinite(queuedCount) ? Math.max(0, queuedCount) : 0; - if (Array.isArray(queue.items)) { - pageUiState.pageAiQueuedItems = queue.items.map(function(item) { - return { - queueId: String(item.queueId || ''), - sessionId: String(item.sessionId || payload.mnoteSessionId || ''), - traceId: String(item.traceId || ''), - preview: String(item.preview || ''), - queuedAt: Number(item.queuedAt || Date.now()) - }; - }).filter(function(item) { return item.queueId; }); - } - } - var acpSessionId = payload.acp && typeof payload.acp === 'object' ? String(payload.acp.acpSessionId || '') : ''; - var current = pageAiCurrentSession(); - if (current && acpSessionId) { - current.acpSessionId = acpSessionId; - current.runtimeMode = String(payload.mode || ''); - current.updatedAt = Date.now(); - pageAiPersistSessions(); - } - document.documentElement.setAttribute('data-mnote-page-ai-runtime-status-loaded', 'true'); - document.documentElement.setAttribute('data-mnote-page-ai-runtime', String(payload.runtime || '')); - document.documentElement.setAttribute('data-mnote-page-ai-runtime-mode', String(payload.mode || '')); - return payload; - } - - async function pageAiLoadRuntimeStatus() { - var params = new URLSearchParams(); - var sessionId = String(pageUiState.pageAiActiveSessionId || '').trim(); - if (sessionId) params.set('sessionId', sessionId); - var workspaceId = String(resolveWorkspaceId(document.body) || '').trim(); - if (workspaceId) params.set('workspaceId', workspaceId); - var documentId = String(currentDocumentId() || '').trim(); - if (documentId) params.set('documentId', documentId); - var profile = String(pageAiCurrentProfile() || '').trim(); - if (profile) params.set('profile', profile); - var response = await fetch('/api/page-ai/runtime/status?' + params.toString(), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'page_ai_runtime_status_failed_' + response.status)); - } - return pageAiApplyRuntimeStatus(payload); - } - - function pageAiActiveRunSnapshotKey() { - return pageAiStorageKey() + ':active-run'; - } - - function pageAiReadActiveRunSnapshot() { - try { - var raw = window.localStorage ? window.localStorage.getItem(pageAiActiveRunSnapshotKey()) : ''; - return raw ? JSON.parse(raw) : null; - } catch (_) { - return null; - } - } - - function pageAiWriteActiveRunSnapshot(snapshot) { - if (!snapshot || !snapshot.hostRunId) return; - try { - window.localStorage.setItem(pageAiActiveRunSnapshotKey(), JSON.stringify(Object.assign({ - schema: 'mnote.page_ai_active_run_snapshot.v1', - createdAt: Date.now() - }, snapshot, { - updatedAt: Date.now() - }))); - document.documentElement.setAttribute('data-mnote-page-ai-active-run-snapshot', 'true'); - } catch (_) {} - } - - function pageAiClearActiveRunSnapshot(runId) { - var existing = pageAiReadActiveRunSnapshot(); - if (runId && existing && existing.hostRunId && existing.hostRunId !== runId) return; - try { - if (window.localStorage) window.localStorage.removeItem(pageAiActiveRunSnapshotKey()); - document.documentElement.removeAttribute('data-mnote-page-ai-active-run-snapshot'); - } catch (_) {} - } - - function pageAiRunStatusIsTerminal(status) { - return ['completed', 'failed', 'aborted', 'cancelled', 'canceled'].indexOf(String(status || '').trim()) >= 0; - } - - function pageAiUpdateActiveRunSnapshotFromStatus(status, runId) { - var id = String(runId || '').trim(); - if (!id) return; - if (pageAiRunStatusIsTerminal(status)) { - pageAiClearActiveRunSnapshot(id); - return; - } - if (['running', 'tool_calling', 'queued', 'pending', 'interrupted', 'acp_pending'].indexOf(String(status || '').trim()) < 0) return; - var previous = pageAiReadActiveRunSnapshot(); - var lastSeq = previous && previous.hostRunId === id ? String(previous.lastSeq || '000000000000000000') : '000000000000000000'; - pageAiWriteActiveRunSnapshot({ - hostRunId: id, - sessionId: pageUiState.pageAiActiveSessionId || '', - lastSeq: lastSeq, - status: status || 'running' - }); - } - - async function pageAiCheckAndResumeActiveRun() { - var snapshot = pageAiReadActiveRunSnapshot(); - var sessionId = snapshot && snapshot.sessionId ? String(snapshot.sessionId || '').trim() : ''; - var run = await pageAiCheckActiveRun(sessionId || undefined); - if (run && sessionId && pageUiState.pageAiActiveSessionId !== sessionId) { - pageAiSetActiveSession(sessionId); - } - if (run) await pageAiResumeActiveRunJournal(run); - if (!run && snapshot && snapshot.hostRunId) { - pageAiStopStreamingAssistantMessage(snapshot.hostRunId); - pageAiClearActiveRunSnapshot(snapshot.hostRunId); - pageUiState.pageAiBusy = false; - pageAiSetRunStatus('idle', snapshot.hostRunId); - } - return run; - } - - function pageAiApplyQueuedRun(payload) { - if (!payload || payload.queued !== true) return false; - var queueId = String(payload.queueId || payload.queue_id || '').trim(); - var queueLength = Number(payload.queueLength || payload.queue_length || 0); - pageUiState.pageAiQueueLength = Number.isFinite(queueLength) ? Math.max(0, queueLength) : 1; - if (queueId) { - pageUiState.pageAiQueuedItems = pageUiState.pageAiQueuedItems.filter(function(item) { - return item.queueId !== queueId; - }).concat([{ - queueId: queueId, - sessionId: String(payload.sessionId || payload.session_id || pageUiState.pageAiActiveSessionId || ''), - traceId: String(payload.traceId || payload.trace_id || ''), - queuedAt: Number(payload.queuedAt || payload.queued_at || Date.now()) - }]); - } - pageAiSetRunStatus('queued', pageUiState.pageAiCurrentRunId); - return true; - } - - function pageAiRefreshClientQueueState() { - var clientItems = pageAiNormalizeArray(pageUiState.pageAiClientQueuedPrompts); - var serverItems = pageAiNormalizeArray(pageUiState.pageAiQueuedItems) - .filter(function(item) { return String(item.queueId || '').indexOf('client_') !== 0; }); - pageUiState.pageAiQueuedItems = serverItems.concat(clientItems.map(function(item) { - return { - queueId: item.queueId, - sessionId: item.sessionId || pageUiState.pageAiActiveSessionId || '', - traceId: item.traceId || '', - preview: item.preview || item.prompt || '', - queuedAt: item.queuedAt || Date.now() - }; - })); - pageUiState.pageAiQueueLength = pageUiState.pageAiQueuedItems.length; - } - - function pageAiEnqueueClientPrompt(prompt) { - var queueId = 'client_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8); - pageUiState.pageAiClientQueuedPrompts.push({ - queueId: queueId, - prompt: prompt, - preview: String(prompt || '').slice(0, 120), - sessionId: pageUiState.pageAiActiveSessionId || '', - queuedAt: Date.now() - }); - pageAiRefreshClientQueueState(); - pageAiSetRunStatus('queued', pageUiState.pageAiCurrentRunId); - return queueId; - } - - function pageAiRemoveClientQueuedPrompt(queueId) { - var before = pageUiState.pageAiClientQueuedPrompts.length; - pageUiState.pageAiClientQueuedPrompts = pageUiState.pageAiClientQueuedPrompts.filter(function(item) { - return item.queueId !== queueId; - }); - pageAiRefreshClientQueueState(); - return before !== pageUiState.pageAiClientQueuedPrompts.length; - } - - function pageAiStartNextClientQueuedPrompt() { - if (pageUiState.pageAiBusy) return false; - if (['running', 'tool_calling', 'pending', 'acp_pending'].indexOf(pageUiState.pageAiRunStatus) >= 0) return false; - var next = pageUiState.pageAiClientQueuedPrompts.shift(); - if (!next) { - pageAiRefreshClientQueueState(); - return false; - } - pageAiRefreshClientQueueState(); - window.setTimeout(function() { - void sendPageAiMessage(next.prompt); - }, 0); - return true; - } - - function pageAiUnwrapSearchCitationUrl(url) { - try { - var raw = ''; - ['wd', 'q', 'query'].some(function(key) { - raw = String(url.searchParams.get(key) || '').trim(); - return Boolean(raw); - }); - if (!raw) return ''; - if (/^documents\//i.test(raw)) raw = '/' + raw; - if (!/^\/documents\//i.test(raw) && !/^https?:\/\/[^/]+\/documents\//i.test(raw) && !/^mnote:\/\/open/i.test(raw)) return ''; - if (/^mnote:\/\/open/i.test(raw)) return raw; - var nested = new URL(raw, window.location.origin); - if (!nested.pathname.startsWith('/documents/')) return ''; - ['sourceKind', 'rootUri', 'workspaceId', 'resourceTab', 'resourcePath', 'page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) { - if (nested.searchParams.get(key)) return; - var value = String(url.searchParams.get(key) || '').trim(); - if (value) nested.searchParams.set(key, value); - }); - return nested.pathname + nested.search; - } catch (_error) { - return ''; - } - } - - function pageAiPreviewValue(value) { - if (value == null || value === '') return ''; - if (typeof value === 'string') return value.length > 120 ? value.slice(0, 120) + '…' : value; - try { - var text = JSON.stringify(value); - return text.length > 120 ? text.slice(0, 120) + '…' : text; - } catch (_) { - return String(value); - } - } - - function pageAiNormalizeToolName(name) { - return String(name || '').trim().replace(/_/g, '.'); - } - - - function pageAiParentRelativePath(path) { - var normalized = String(path || '').trim().replace(/\\/g, '/').replace(/^\/+/, ''); - if (!normalized || normalized.indexOf('/') < 0) return ''; - return normalized.split('/').slice(0, -1).join('/'); - } - - function pageAiChangedFileRelativePath(file) { - return String(file && (file.path || file.relativePath || file.relative_path || file.filePath || file.file_path || '') || '').trim(); - } - - function pageAiDispatchReceiptRefresh(receipt, changedFiles, fallbackAudit, runId, traceId) { - var refresh = receipt && typeof receipt === 'object' && receipt.refresh && typeof receipt.refresh === 'object' - ? receipt.refresh - : {}; - var files = pageAiNormalizeArray( - receipt && typeof receipt === 'object' && receipt.changedFiles !== undefined - ? receipt.changedFiles - : changedFiles - ); - if (files.length) { - var changedPaths = files.map(function(file) { - var relativePath = pageAiChangedFileRelativePath(file); - return { - relativePath: relativePath, - changeType: String(file && (file.changeType || file.change_type || 'modified') || 'modified') - }; - }).filter(function(item) { return item.relativePath; }); - var affectedParents = changedPaths.map(function(item) { - return { relativePath: pageAiParentRelativePath(item.relativePath), reason: 'agent-run-receipt' }; - }).filter(function(item, index, list) { - return index === list.findIndex(function(candidate) { return candidate.relativePath === item.relativePath; }); - }); - if (changedPaths.length) { - var rootUri = String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || ''); - var syntheticPayload = { - schema: 'mnote.local_folder.watch_batch.v1', - source: 'agent_run_receipt', - runId: runId, - rootUri: rootUri, - changedPaths: changedPaths, - affectedParents: affectedParents - }; - document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true'); - if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') { - window.__mnoteLocalFolderEventBus.emitChangedFiles({ - source: 'synthetic_page_ai_receipt', - reason: 'agent_run_receipt', - rootUri: rootUri, - workspaceId: resolveWorkspaceId(document.body), - changedFiles: changedPaths, - affectedParents: affectedParents, - payload: syntheticPayload - }); - } else { - document.documentElement.setAttribute('data-mnote-page-ai-receipt-fallback-direct', 'true'); - window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', { - detail: { payload: syntheticPayload } - })); - } - } - } - if (refresh.touchesCurrentFile === true && !files.length) { - var documentId = String(refresh.currentDocumentId || receipt && receipt.documentId || currentDocumentId() || '').trim(); - document.documentElement.setAttribute('data-mnote-page-ai-receipt-current-refresh', 'true'); - window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { - detail: { - toolName: 'agent.run_receipt', - normalizedToolName: 'agent.run_receipt', - documentId: documentId, - workspaceId: String(receipt && receipt.workspaceId || resolveWorkspaceId(document.body) || ''), - rootUri: String(receipt && receipt.rootUri || fallbackAudit && fallbackAudit.rootUri || currentRootUri() || ''), - changedFiles: files, - receipt: receipt || null, - runId: runId, - traceId: traceId, - toolCallId: runId + ':agent.run_receipt' - } - })); - } - } - - function pageAiToolEventDeepFindString(value, keys, depth) { - if (!value || typeof value !== 'object' || depth > 5) return ''; - for (var index = 0; index < keys.length; index += 1) { - var key = keys[index]; - if (Object.prototype.hasOwnProperty.call(value, key) && typeof value[key] === 'string' && value[key].trim()) { - return value[key].trim(); - } - } - if (Array.isArray(value)) { - for (var arrayIndex = 0; arrayIndex < value.length; arrayIndex += 1) { - var fromArray = pageAiToolEventDeepFindString(value[arrayIndex], keys, depth + 1); - if (fromArray) return fromArray; - } - return ''; - } - var preferred = ['audit', 'args', 'arguments', 'input', 'result', 'summary', 'output', 'upstream']; - for (var prefIndex = 0; prefIndex < preferred.length; prefIndex += 1) { - var child = value[preferred[prefIndex]]; - var fromPreferred = pageAiToolEventDeepFindString(child, keys, depth + 1); - if (fromPreferred) return fromPreferred; - } - var objectKeys = Object.keys(value); - for (var objectIndex = 0; objectIndex < objectKeys.length; objectIndex += 1) { - var fromObject = pageAiToolEventDeepFindString(value[objectKeys[objectIndex]], keys, depth + 1); - if (fromObject) return fromObject; - } - return ''; - } - - function pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId) { - var normalizedTool = pageAiNormalizeToolName(toolName); - var writesCurrentPage = [ - 'mnote.page.save', - 'mnote.page.update.title', - 'mnote.page.update.options', - 'mnote.doc.apply.block.ops', - 'mnote.block.replace', - 'mnote.block.insert.after', - 'mnote.block.delete', - 'mnote.block.move.after' - ].indexOf(normalizedTool) >= 0 || [ - 'mnote.page.update_title', - 'mnote.page.update_options', - 'mnote.doc.apply_block_ops', - 'mnote.block.replace', - 'mnote.block.insert_after', - 'mnote.block.delete', - 'mnote.block.move_after' - ].indexOf(String(toolName || '').trim()) >= 0; - if (!writesCurrentPage) return; - var documentId = pageAiToolEventDeepFindString(toolEvent, ['documentId', 'document_id'], 0) || currentDocumentId(); - var workspaceId = pageAiToolEventDeepFindString(toolEvent, ['workspaceId', 'workspace_id'], 0) || resolveWorkspaceId(document.body); - try { - window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { - detail: { - toolName: toolName, - normalizedToolName: normalizedTool, - documentId: documentId, - workspaceId: workspaceId, - runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId || ''), - traceId: String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''), - toolCallId: String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || '') - } - })); - } catch (error) { - console.warn('mnote 页面 AI 写入刷新事件派发失败', error); - } - } - - function pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId) { - var toolEvent = null; - try { - toolEvent = JSON.parse(payloadText || 'null'); - } catch (_) { - toolEvent = {}; - } - var rawToolName = toolEvent && (toolEvent.name || toolEvent.tool || toolEvent.toolName || ''); - var toolName = String(rawToolName || eventName); - var toolCallId = String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || (runId + ':' + toolName)); - var eventStatus = String(toolEvent && toolEvent.status || '').trim(); - var status = eventName === 'tool.completed' - ? (toolEvent && toolEvent.error ? 'failed' : 'completed') - : (eventName === 'tool.failed' ? 'failed' : (eventStatus || 'running')); - if (status === 'in_progress' || status === 'pending') status = 'running'; - var traceId = String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''); - var auditId = String(toolEvent && (toolEvent.audit_id || toolEvent.auditId) || ''); - var argsSummary = pageAiPreviewValue(toolEvent && (toolEvent.arguments || toolEvent.args || toolEvent.input)); - var resultSource = toolEvent && (toolEvent.summary || toolEvent.result || toolEvent.output); - if (!resultSource && status === 'failed') { - resultSource = [toolEvent && toolEvent.code, toolEvent && toolEvent.error].filter(Boolean).join(' '); - } - var resultSummary = pageAiPreviewValue(resultSource); - pageUiState.pageAiLastToolCall = { - event: eventName, - name: toolName, - runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId), - traceId: traceId, - auditId: auditId - }; - var rawLocations = toolEvent && toolEvent.locations; - var locations = Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : []; - var existing = pageUiState.pageAiMessages.find(function(item) { - return item.role === 'tool' && item.toolCallId === toolCallId; - }); - if (!rawToolName && existing && existing.toolName) toolName = existing.toolName; - if (!existing) { - existing = { - role: 'tool', - content: toolName, - toolCallId: toolCallId, - toolName: toolName, - toolKind: String(toolEvent && toolEvent.kind || ''), - status: status, - argsSummary: '', - resultSummary: '', - locations: locations, - traceId: traceId, - auditId: auditId - }; - pageUiState.pageAiMessages.push(existing); - } - existing.content = toolName; - existing.toolName = toolName; - existing.toolKind = String(toolEvent && toolEvent.kind || existing.toolKind || ''); - existing.status = status; - existing.traceId = traceId || existing.traceId || ''; - existing.auditId = auditId || existing.auditId || ''; - if (argsSummary) existing.argsSummary = argsSummary; - if (resultSummary) existing.resultSummary = resultSummary; - if (locations.length) existing.locations = locations; - existing.rawOutput = toolEvent && toolEvent.output; - existing.rawResult = resultSource; - if (status === 'completed') { - pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId); - } - return existing; - } - - async function pageAiCancelQueuedRun(queueId) { - queueId = String(queueId || '').trim(); - if (!queueId) return; - if (queueId.indexOf('client_') === 0) { - if (pageAiRemoveClientQueuedPrompt(queueId)) { - pageUiState.pageAiMessages.push({ role: 'assistant', content: '已取消一条 AI 队列项。' }); - } - renderPageAiConversation(); - renderPageAiControls(); - return; - } - var item = pageUiState.pageAiQueuedItems.find(function(entry) { - return entry.queueId === queueId; - }); - var sessionId = String(item && item.sessionId || pageUiState.pageAiActiveSessionId || '').trim(); - if (!sessionId) return; - try { - var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/queue/' + encodeURIComponent(queueId), { - method: 'DELETE', - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'queue_cancel_failed_' + response.status)); - pageUiState.pageAiQueuedItems = pageUiState.pageAiQueuedItems.filter(function(entry) { - return entry.queueId !== queueId; - }); - var queueLength = Number(payload && (payload.queueLength || payload.queue_length || 0)); - pageUiState.pageAiQueueLength = Number.isFinite(queueLength) ? Math.max(0, queueLength) : pageUiState.pageAiQueuedItems.length; - pageUiState.pageAiMessages.push({ role: 'assistant', content: '已取消一条 AI 队列项。' }); - } catch (error) { - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '取消 AI 队列项失败:' + (error instanceof Error ? error.message : String(error)) - }); - } - renderPageAiConversation(); - renderPageAiControls(); - } - - async function pageAiResetRuntime() { - var sessionId = String(pageUiState.pageAiActiveSessionId || '').trim(); - if (!sessionId) return; - try { - var response = await fetch('/api/page-ai/runtime/reset', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - sessionId: sessionId, - runtime: pageUiState.pageAiAcpRuntime || '', - profile: pageAiCurrentProfile() - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'runtime_reset_failed_' + response.status)); - } - pageUiState.pageAiRuntimeStatus = null; - pageUiState.pageAiRuntimeStatusError = ''; - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '已重置 runtime;MNote 会话历史保留,外部 provider 会话未删除。' - }); - await pageAiLoadRuntimeStatus().catch(function(error) { - pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error); - return null; - }); - } catch (error) { - pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error); - } - renderPageAiConversation(); - renderPageAiControls(); - } - - function pageAiSetContextScope(scope) { - var next = String(scope || '').trim() || 'page'; - pageUiState.pageAiContextScope = next; - document.documentElement.setAttribute('data-mnote-page-ai-context-scope', next); - } - - - function pageAiHermesSettingsUrl() { - var configured = String(window.__mnoteHermesSettingsUrl || '').trim(); - return configured || ''; - } - - function pageAiOpenHermesSettings() { - var url = pageAiHermesSettingsUrl(); - if (url) { - window.open(url, '_blank', 'noopener,noreferrer'); - return; - } - pageUiState.pageAiProfileError = '未配置 Hermes 设置入口:请设置 MNOTE_WEB_HERMES_UPSTREAM_URL。'; - renderPageAiControls(); - } - - function pageAiNormalizeTools(payload) { - var upstream = pageAiUnwrapUpstream(payload) || {}; - var tools = pageAiNormalizeArray(upstream.tools || upstream); - return tools.map(function(tool) { - var name = String(tool && (tool.name || tool.toolName || tool.tool) || '').trim(); - if (!name) return null; - return { - name: name, - description: String(tool && tool.description || '').trim(), - scope: String(tool && (tool.scope || tool.requiredScope || '') || '').trim(), - kind: String(tool && (tool.kind || tool.mode || '') || '').trim(), - status: String(tool && (tool.status || tool.permission || 'available') || '').trim(), - enabled: tool && tool.enabled !== false, - unavailableReason: String(tool && (tool.unavailableReason || tool.reason || '') || '').trim() - }; - }).filter(Boolean); - } - - function pageAiErrorMessage(payload, fallback) { - if (!payload || typeof payload !== 'object') return fallback; - return String(payload.message || payload.error || payload.code || fallback || '').trim() || fallback; - } - - function pageAiNormalizeGatewayHealth(payload) { - var upstream = pageAiUnwrapUpstream(payload) || payload || {}; - return { - ok: Boolean(upstream.ok), - profile: upstream.profile || null, - gateway: upstream.gateway || null, - suggestions: pageAiNormalizeArray(upstream.suggestions).map(function(item) { - return String(item || '').trim(); - }).filter(Boolean) - }; - } - - function pageAiSetDraftForSection(section, value) { - pageUiState.pageAiProfileMemoryDrafts[section] = String(value == null ? '' : value); - } - - function pageAiApplyProfileMemory(payload) { - var upstream = pageAiUnwrapUpstream(payload) || {}; - pageUiState.pageAiProfileMemory = { - memory: String(upstream.memory || ''), - user: String(upstream.user || ''), - soul: String(upstream.soul || '') - }; - pageUiState.pageAiProfileMemoryDrafts = { - memory: pageUiState.pageAiProfileMemory.memory, - user: pageUiState.pageAiProfileMemory.user, - soul: pageUiState.pageAiProfileMemory.soul - }; - } - - async function pageAiLoadTools() { - try { - var descriptor = pageAiAgentDescriptor(pageAiCurrentAgentId()); - if (descriptor && pageUiState.pageAiAgentDescriptorsLoaded) { - pageUiState.pageAiTools = pageAiNormalizeArray(descriptor.tools); - pageUiState.pageAiToolsError = ''; - renderPageAiControls(); - return; - } - var response = await fetch('/api/hermes/client/tools?scope=mnote&profile=' + encodeURIComponent(pageAiCurrentProfile()), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tools_failed_' + response.status)); - pageUiState.pageAiTools = pageAiNormalizeTools(payload); - pageUiState.pageAiToolsError = ''; - } catch (error) { - pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - } - - async function pageAiLoadGatewayHealth() { - try { - var response = await fetch('/api/hermes/client/gateway/health?profile=' + encodeURIComponent(pageAiCurrentProfile()), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'gateway_health_failed_' + response.status)); - pageUiState.pageAiGatewayHealth = pageAiNormalizeGatewayHealth(payload); - pageUiState.pageAiGatewayHealthError = ''; - } catch (error) { - pageUiState.pageAiGatewayHealthError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - } - - async function pageAiStopRun() { - var runId = String(pageUiState.pageAiCurrentRunId || '').trim(); - if (!runId || pageUiState.pageAiRunStatus === 'idle' || pageUiState.pageAiRunStatus === 'completed') return; - pageUiState.pageAiStoppedRunIds[runId] = true; - try { - if (pageAiBoardFirstEnabled()) { - var boardResponse = await fetch('/api/page-ai/board/runs/' + encodeURIComponent(runId) + '/cancel', { - method: 'POST', - headers: { accept: 'application/json' } - }); - var boardPayload = await boardResponse.json().catch(function(){ return null; }); - if (!boardResponse.ok) throw new Error(pageAiErrorMessage(boardPayload, 'board_run_cancel_failed_' + boardResponse.status)); - pageAiSetRunStatus('cancelled', runId); - pageUiState.pageAiMessages.push({ role: 'assistant', content: '已请求停止当前 Board run。', boardRunId: runId }); - return; - } - var response = await fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/abort', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - sessionId: pageUiState.pageAiActiveSessionId, - profile: pageAiRunProfile(), - acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - reason: 'page_ai_user_stop' - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'run_abort_failed_' + response.status)); - pageAiApplyRuntimeState(payload && payload.runtime); - pageAiSetRunStatus('aborted', runId); - pageUiState.pageAiMessages.push({ role: 'assistant', content: '已请求停止当前 AI run。' }); - } catch (error) { - pageAiSetRunStatus('failed', runId); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '停止 AI run 失败:' + (error instanceof Error ? error.message : String(error)) - }); - } - renderPageAiControls(); - renderPageAiConversation(); - } - - - function pageAiLoadSkillCatalog(runtime, profile) { - var params = new URLSearchParams(); - params.set('runtime', runtime); - var endpoint = '/api/hermes/client/skills'; - if (runtime === 'mnote') { - endpoint = '/api/hermes/client/capabilities'; - params.set('agentId', 'reasonix'); - if (profile) params.set('profile', profile); - } else if (runtime === 'reasonix') { - params.set('runtime', 'reasonix'); - } else if (runtime === 'hermes' && profile) { - params.set('profileId', profile); - } - return fetch(endpoint + '?' + params.toString(), { - headers: { 'accept': 'application/json' } - }).then(function(response) { - return response.json().catch(function(){ return null; }).then(function(payload) { - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'capabilities_failed_' + response.status)); - return pageAiNormalizeSkills(payload); - }); - }); - } - - async function pageAiLoadProfiles() { - try { - var response = await fetch('/api/ai/agent-profiles?agentId=hermes', { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profiles_failed_' + response.status)); - var profiles = pageAiNormalizeProfiles(payload); - pageUiState.pageAiProfiles = profiles.length ? profiles : [{ profileId: 'shared_lite', name: 'shared_lite', alias: 'Lite', kind: 'shared', readonly: true, canManageSkills: false }]; - try { - var runtimeResponse = await fetch('/api/hermes/client/profiles', { headers: { 'accept': 'application/json' } }); - var runtimePayload = await runtimeResponse.json().catch(function(){ return null; }); - var acpRuntimes = Array.isArray(runtimePayload && runtimePayload.acpRuntimes) ? runtimePayload.acpRuntimes : []; - pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(acpRuntimes); - } catch (_) { - pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(pageUiState.pageAiAcpRuntimes); - } - var current = pageAiCurrentProfile(); - var hasCurrent = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === current; }); - var personal = pageUiState.pageAiProfiles.find(function(profile) { return profile.kind === 'personal'; }); - var active = pageAiProfileValue(pageUiState.pageAiProfiles.find(function(profile) { return profile.active; })); - pageAiSetActiveProfile(hasCurrent && current !== 'default' ? current : (active || pageAiProfileValue(personal) || pageAiProfileValue(pageUiState.pageAiProfiles[0]) || current)); - pageUiState.pageAiProfileError = ''; - void pageAiLoadAgentDescriptors(); - void pageAiLoadGatewayHealth(); - } catch (error) { - pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); - if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ profileId: 'shared_lite', name: 'shared_lite', alias: 'Lite', kind: 'shared', readonly: true, canManageSkills: false }]; - pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(pageUiState.pageAiAcpRuntimes); - pageAiSetActiveProfile(pageAiCurrentProfile()); - void pageAiLoadAgentDescriptors(); - } - renderPageAiProviderButtons(); - renderPageAiControls(); - } - - async function pageAiSwitchProfile(profileName) { - var next = String(profileName || '').trim(); - if (!next) return; - var previousSkillSource = pageAiCurrentSkillSource(); - pageAiSetActiveProfile(next); - pageAiPersistRawAiPreference('ai.agent.hermes.profile_id', next); - if (previousSkillSource.indexOf('hermes:') === 0) { - pageUiState.pageAiActiveSkillSource = 'hermes:' + next; - pageAiPersistAiPreference('skills.active_source', pageUiState.pageAiActiveSkillSource); - } - pageUiState.pageAiSkills = { categories: [], archived: [] }; - pageUiState.pageAiSkillCatalogs = Object.assign({}, pageUiState.pageAiSkillCatalogs || {}, { - hermes: { categories: [], archived: [] } - }); - pageUiState.pageAiSkillError = ''; - renderPageAiProviderButtons(); - renderPageAiControls(); - try { - pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(session) { - return session && session.profile === next; - }); - pageUiState.pageAiActiveSessionId = ''; - pageUiState.pageAiMessages = []; - pageAiPersistSessions(); - await pageAiEnsureHermesSession(true); - await pageAiLoadProfileMemory(); - await pageAiLoadAgentDescriptors(); - await pageAiLoadSkills(); - await pageAiLoadTools(); - await pageAiLoadGatewayHealth(); - renderPageAiControls(); - } catch (error) { - pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); - } - renderPageAiConversation(); - } - - async function pageAiLoadProfileMemory() { - try { - var response = await fetch('/api/hermes/client/profile-memory?profileId=' + encodeURIComponent(pageAiCurrentProfile()), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_memory_failed_' + response.status)); - pageAiApplyProfileMemory(payload); - pageUiState.pageAiProfileMemoryError = ''; - } catch (error) { - pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiSaveProfileMemory(section) { - var normalized = String(section || '').trim(); - if (['memory', 'user', 'soul'].indexOf(normalized) < 0) return; - var content = String(pageUiState.pageAiProfileMemoryDrafts[normalized] || ''); - try { - var response = await fetch('/api/hermes/client/profile-memory', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - profileId: pageAiCurrentProfile(), - section: normalized, - content: content - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_memory_save_failed_' + response.status)); - pageUiState.pageAiProfileMemory[normalized] = content; - pageUiState.pageAiProfileMemoryError = ''; - document.documentElement.setAttribute('data-mnote-page-ai-memory-saved', normalized); - } catch (error) { - pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiLoadSkills() { - var requestedSource = pageAiCurrentSkillSource(); - var sourceParts = pageAiSkillSourceParts(requestedSource); - var loadSeq = (Number(pageUiState.pageAiSkillLoadSeq || 0) || 0) + 1; - pageUiState.pageAiSkillLoadSeq = loadSeq; - try { - var catalog = await pageAiLoadSkillCatalog(sourceParts.group, sourceParts.profile || pageAiCurrentProfile()); - if (pageUiState.pageAiSkillLoadSeq !== loadSeq - || pageAiCurrentSkillSource() !== requestedSource) return; - pageUiState.pageAiSkillCatalogs = Object.assign({}, pageUiState.pageAiSkillCatalogs || {}, { - [sourceParts.group]: catalog, - [requestedSource]: catalog - }); - pageUiState.pageAiSkills = catalog; - pageUiState.pageAiSkillError = ''; - } catch (error) { - if (pageUiState.pageAiSkillLoadSeq !== loadSeq - || pageAiCurrentSkillSource() !== requestedSource) return; - pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); - pageUiState.pageAiSkills = { categories: [], archived: [] }; - pageUiState.pageAiSkillCatalogs = pageUiState.pageAiSkillCatalogs || { mnote: { categories: [], archived: [] }, reasonix: { categories: [], archived: [] }, hermes: { categories: [], archived: [] } }; - } - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiToggleSkill(skillName, enabled, group, profile) { - var name = String(skillName || '').trim(); - if (!name) return; - var skillGroup = String(group || 'hermes').trim() || 'hermes'; - var skillProfile = String(profile || pageAiCurrentProfile() || '').trim(); - if (skillGroup === 'reasonix') { - pageAiSetSkillPreference(skillGroup, name, Boolean(enabled), skillProfile); - document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', skillGroup + ':' + name); - renderPageAiControls(); - return; - } - if (skillGroup === 'mnote') { - try { - var mnoteResponse = await fetch('/api/hermes/client/capabilities/toggle', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - runtime: 'mnote', - profile: skillProfile || pageAiCurrentProfile(), - id: name, - enabled: Boolean(enabled) - }) - }); - var mnotePayload = await mnoteResponse.json().catch(function(){ return null; }); - if (!mnoteResponse.ok) throw new Error(pageAiErrorMessage(mnotePayload, 'mnote_capability_toggle_failed_' + mnoteResponse.status)); - pageAiSetSkillPreference(skillGroup, name, Boolean(enabled), skillProfile); - pageAiNormalizeArray(pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs.mnote && pageUiState.pageAiSkillCatalogs.mnote.categories).forEach(function(category) { - pageAiNormalizeArray(category.skills).forEach(function(skill) { - if (skill.id === name || skill.name === name) { - skill.enabled = Boolean(enabled); - skill.status = enabled ? 'available' : 'disabled'; - pageAiNormalizeArray(skill.tools).forEach(function(tool) { - tool.enabled = Boolean(enabled); - tool.status = enabled ? 'available' : 'disabled'; - }); - skill.disabledToolCount = enabled ? 0 : Number(skill.toolCount || pageAiNormalizeArray(skill.tools).length || 0); - } - }); - }); - document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', skillGroup + ':' + name); - } catch (error) { - pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - return; - } - var previous = null; - pageAiToggleableSkillEntries('hermes', skillProfile || pageAiCurrentProfile()).forEach(function(skill) { - if (skill.id === name || skill.name === name) previous = skill.enabled !== false; - }); - try { - var response = await fetch('/api/hermes/client/skills/toggle', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - profile: skillProfile || pageAiCurrentProfile(), - profileId: skillProfile || pageAiCurrentProfile(), - skillKind: 'hermes_profile', - name: name, - enabled: Boolean(enabled) - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skill_toggle_failed_' + response.status)); - var hermesCatalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs.hermes - ? pageUiState.pageAiSkillCatalogs.hermes - : pageUiState.pageAiSkills; - pageAiNormalizeArray(hermesCatalog.categories).forEach(function(category) { - pageAiNormalizeArray(category.skills).forEach(function(skill) { - if (skill.name === name || skill.id === name) skill.enabled = Boolean(enabled); - }); - }); - pageUiState.pageAiSkills = hermesCatalog; - pageAiSetSkillPreference('hermes', name, Boolean(enabled), skillProfile || pageAiCurrentProfile()); - document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', name); - pageUiState.pageAiSkillError = ''; - } catch (error) { - pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); - if (previous != null) { - var rollbackCatalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs.hermes - ? pageUiState.pageAiSkillCatalogs.hermes - : pageUiState.pageAiSkills; - pageAiNormalizeArray(rollbackCatalog.categories).forEach(function(category) { - pageAiNormalizeArray(category.skills).forEach(function(skill) { - if (skill.name === name || skill.id === name) skill.enabled = previous; - }); - }); - } - } - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiToggleTool(toolName, enabled) { - var name = String(toolName || '').trim(); - if (!name) return; - var previous = null; - pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { - if (tool.name === name && previous == null) previous = tool.enabled !== false; - }); - try { - var response = await fetch('/api/hermes/client/tools/toggle', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - profile: pageAiCurrentProfile(), - name: name, - enabled: Boolean(enabled) - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tool_toggle_failed_' + response.status)); - pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { - if (tool.name === name) { - tool.enabled = Boolean(enabled); - tool.status = Boolean(enabled) ? 'available' : 'disabled'; - tool.unavailableReason = Boolean(enabled) ? '' : '当前 Hermes profile 已关闭该 mnote tool'; - } - }); - document.documentElement.setAttribute('data-mnote-page-ai-tool-toggled', name); - pageUiState.pageAiToolsError = ''; - } catch (error) { - pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error); - if (previous != null) { - pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { - if (tool.name === name) tool.enabled = previous; - }); - } - } - renderPageAiControls(); - } - - function openPageAiDrawer() { - if (pageAiOpencodeHostEnabled()) { - pageAiEnsureContextRefState(); - void pageAiLoadAllowedRoots().then(function() { - pageAiRenderOpencodeHostChrome(); - }).catch(function() { - pageAiRenderOpencodeHostChrome(); - }); - var opencodeDrawer = pageAiEnsureOpencodeHostDrawer(); - pageAiApplyDrawerWidth(opencodeDrawer); - opencodeDrawer.hidden = false; - pageUiState.pageAiOpen = true; - updatePageAiTriggerState(); - void pageAiLoadOpencodeStatus(); - return; + // 产品唯一入口:Pi Lab。旧 Hermes/OpenCode/Board drawer 不再打开。 + if (window.createSidebarPageAiPiLabRuntime) { + window.createSidebarPageAiPiLabRuntime({}); } - pageUiState.pageAiAgentId = pageAiCurrentAgentId(); - pageAiEnsureContextRefState(); - pageAiLoadSessions(); - pageAiRestorePermissionRequests(); - renderPageAiSuggestions(); - renderPageAiConversation(); - renderPageAiProviderButtons(); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - var drawer = ensurePageAiDrawer(); - pageAiApplyDrawerWidth(drawer); - drawer.hidden = false; - pageUiState.pageAiOpen = true; + window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, window.location.origin); + if (pageUiState) pageUiState.pageAiOpen = true; updatePageAiTriggerState(); - void pageAiCheckAndResumeActiveRun().catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }).then(function() { - return pageAiLoadRuntimeStatus().catch(function(error) { - pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - return null; - }); - }); - void pageAiLoadBackendSessions().then(function() { - return pageAiCheckAndResumeActiveRun(); - }).catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - }); - Promise.all([ - pageAiLoadProfiles(), - pageAiLoadProfileMemory(), - pageAiLoadAgentDescriptors(), - pageAiLoadSkills(), - pageAiLoadTools(), - pageAiLoadGatewayHealth(), - pageAiLoadAiPreferences(), - pageAiLoadBoardCatalog(), - pageAiLoadAllowedRoots(), - pageAiLoadBackendSessions(), - pageAiLoadRuntimeStatus() - ]).then(function() { - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - }).then(function() { - return pageAiCheckAndResumeActiveRun().catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - return null; - }); - }).then(function() { - if (!pageAiBoardFirstEnabled()) return pageAiRestoreHermesSession(); - return null; - }).then(function() { - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - }).catch(function(error) { - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '当前 AI 不可用:' + (error instanceof Error ? error.message : String(error)) - }); - renderPageAiConversation(); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - }); } function closePageAiDrawer() { - var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - if (drawer instanceof HTMLElement) drawer.hidden = true; - pageUiState.pageAiOpen = false; + window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-hide' }, window.location.origin); + if (pageUiState) pageUiState.pageAiOpen = false; updatePageAiTriggerState(); } function isPageAiDrawerOpen() { - var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - return drawer instanceof HTMLElement && !drawer.hidden; - } - - async function streamPageAiResponse(response, onEvent) { - if (!response.body || typeof response.body.getReader !== 'function') return; - var reader = response.body.getReader(); - var decoder = new TextDecoder(); - var buffer = ''; - while (true) { - var chunk = await reader.read(); - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - var frames = buffer.split('\n\n'); - buffer = frames.pop() || ''; - frames.forEach(function(frame) { - var eventName = ''; - var dataLines = []; - frame.split('\n').forEach(function(line) { - if (line.startsWith('event:')) eventName = line.slice(6).trim(); - if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); - }); - var payloadText = dataLines.join('\n'); - if (!eventName && payloadText) { - try { - var parsed = JSON.parse(payloadText); - eventName = parsed && parsed.event ? String(parsed.event) : ''; - } catch (_) {} - } - if (eventName) onEvent(eventName, payloadText); - }); - } - } - - function pageAiDecodeDeltaText(payloadText) { - try { - var payload = JSON.parse(payloadText || 'null'); - return String((payload && (payload.text || payload.delta)) || ''); - } catch (_) { - return String(payloadText || ''); - } - } - - function pageAiEventSeqFromPayloadText(payloadText) { - try { - var payload = JSON.parse(payloadText || 'null'); - return String((payload && payload.seq) || '').trim(); - } catch (_) { - return ''; - } - } - - function pageAiTrackStreamingRunEvent(runId, eventName, payloadText) { - var seq = pageAiEventSeqFromPayloadText(payloadText); - if (!seq) return; - pageAiWriteActiveRunSnapshot({ - hostRunId: runId, - sessionId: pageUiState.pageAiActiveSessionId || '', - lastSeq: seq, - status: pageAiRunStatusIsTerminal(eventName) ? pageUiState.pageAiRunStatus : (pageUiState.pageAiRunStatus || 'running') - }); - document.documentElement.setAttribute('data-mnote-page-ai-active-run-last-seq', seq); - } - - function pageAiEnsureStreamingAssistantMessage(runId) { - var id = String(runId || pageUiState.pageAiCurrentRunId || ''); - var existing = pageUiState.pageAiMessages.find(function(item) { - return item.role === 'assistant' && item.streaming === true && item.runId === id; - }); - if (existing) return existing; - existing = { - role: 'assistant', - content: '', - runId: id, - streaming: true - }; - pageUiState.pageAiMessages.push(existing); - return existing; - } - - function pageAiAppendStreamingAssistantDelta(runId, deltaText) { - var delta = String(deltaText || ''); - if (!delta) return ''; - var message = pageAiEnsureStreamingAssistantMessage(runId); - message.content = String(message.content || '') + delta; - pageAiSyncCurrentSessionMessages(); - renderPageAiConversation(); - return message.content; - } - - function pageAiAppendCitationSection(content, citations) { - var text = String(content || ''); - var unique = []; - pageAiNormalizeArray(citations).forEach(function(value) { - var citation = String(value || '').trim(); - if (!citation || unique.indexOf(citation) >= 0) return; - unique.push(citation); - }); - var missing = unique.filter(function(citation) { - return text.indexOf(citation) < 0; - }); - if (!missing.length) return text; - return text.replace(/\s+$/g, '') + '\n\n**引用**\n' + missing.map(function(citation) { - return '- ' + citation; - }).join('\n'); - } - - function pageAiPromptNeedsKnowledgeRagCitations(prompt) { - var text = searchText(prompt); - if (!text) return false; - var asksCitation = ['链接', '引用', '来源', '出处', '证据', '定位', 'link', 'citation', 'source'].some(function(word) { - return text.indexOf(word) >= 0; - }); - if (!asksCitation) return false; - return ['weknora', '资料库', '知识库', 'rag', '文献', '论文', '保护基'].some(function(word) { - return text.indexOf(word) >= 0; - }); - } - - function pageAiKnowledgeRagFallbackQuery(prompt) { - var text = searchText(prompt); - if (!text) return ''; - var afterColon = text.split(/[::]/).slice(1).join(':').trim(); - var candidate = afterColon || text; - candidate = candidate - .replace(/^(请|请用|帮我|帮忙|用)?(资料库|知识库|weknora|rag)?(搜索|检索|查找|查询|回答|说明|解释|总结)?/i, '') - .replace(/(请)?(给出|给我|附上|提供)?(链接|引用|来源|出处|证据|定位).*$/i, '') - .trim(); - var stopIndex = candidate.search(/(在|的|是|有哪些|有什么|如何|怎么|用于|用途|作用|资料|文献|论文)/); - if (stopIndex > 0) candidate = candidate.slice(0, stopIndex).trim(); - var cjkMatch = candidate.match(/[A-Za-z0-9\u4e00-\u9fff·α-ωΑ-Ω\-]{2,24}/); - if (cjkMatch && cjkMatch[0]) return cjkMatch[0]; - return text; - } - - function pageAiCleanDegradedCitationNotes(content) { - return String(content || '') - .split('\n') - .filter(function(line) { - var text = String(line || ''); - return text.indexOf('来源定位降级') < 0 && text.toLowerCase().indexOf('locator degraded') < 0; - }) - .join('\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); - } - - function pageAiCitationPrecisionLabel(value) { - var precision = searchText(value || '').toLowerCase(); - if (precision === 'bbox') return '精确定位'; - if (precision === 'paragraph') return '段落级'; - if (precision === 'file') return '文件级'; - return ''; - } - - function pageAiCitationSourceTitle(citation) { - if (!citation || typeof citation !== 'object') return ''; - var path = searchText(citation.sourceRootRelativePath || citation.sourcePath || citation.filePath || citation.lightRagFilePath || ''); - if (path) { - var parts = path.split('/').filter(Boolean); - return parts[parts.length - 1] || path; - } - var markdown = searchText(citation.citationMarkdown || ''); - var labelMatch = markdown.match(/^\[([^\]]+)\]/); - return labelMatch ? labelMatch[1] : ''; - } - - function pageAiCitationDisplayMarkdown(value) { - if (typeof value === 'string') return value.trim(); - if (!value || typeof value !== 'object') return ''; - var markdown = searchText(value.citationMarkdown || ''); - var citationId = searchText(value.citationLabel || (value.citationId ? '[' + value.citationId + ']' : '')); - var title = pageAiCitationSourceTitle(value); - var headingPath = pageAiNormalizeArray(value.headingPath).map(function(item) { - return searchText(item); - }).filter(Boolean).join(' > '); - var precisionLabel = pageAiCitationPrecisionLabel(value.locatorPrecision); - var quote = searchText(value.displayQuote || value.quote || ''); - if (quote.length > 120) quote = quote.slice(0, 117) + '...'; - var head = markdown || title; - if (citationId && head.indexOf(citationId) < 0) head = citationId + ' ' + head; - var details = []; - if (precisionLabel) details.push(precisionLabel); - if (headingPath) details.push(headingPath); - if (quote) details.push(quote); - return [head].concat(details).filter(Boolean).join(' · ').trim(); - } - - function pageAiFilterCitationCards(values) { - var cards = pageAiNormalizeArray(values); - var hasPreciseCitation = cards.some(function(value) { - if (typeof value === 'string') return value.indexOf('来源定位降级') < 0; - return value && typeof value === 'object' && searchText(value.citationMarkdown || '') && value.locatorDegraded !== true; - }); - var seen = {}; - return cards.map(pageAiCitationDisplayMarkdown).filter(function(card, index) { - var source = cards[index]; - if (!card || seen[card]) return false; - if (hasPreciseCitation) { - if (typeof source === 'string' && source.indexOf('来源定位降级') >= 0) return false; - if (source && typeof source === 'object' && source.locatorDegraded === true) return false; - } - seen[card] = true; - return true; - }).slice(0, 5); - } - - function pageAiCollectKnowledgeRagApiCitations(payload) { - var structuredCitations = pageAiNormalizeArray(payload && payload.citations); - if (structuredCitations.length) { - return pageAiFilterCitationCards(structuredCitations); - } - var references = pageAiNormalizeArray(payload && payload.references); - return pageAiFilterCitationCards(references); - } - - async function pageAiAppendKnowledgeRagFallbackCitations(runId, promptText) { - if (!pageAiPromptNeedsKnowledgeRagCitations(promptText)) return; - var rootUri = currentRootUri(); - if (!rootUri) return; - var fallbackQuery = pageAiKnowledgeRagFallbackQuery(promptText); - if (!fallbackQuery) return; - try { - var response = await fetch('/api/knowledge-rag/search', { - method: 'POST', - headers: { 'content-type': 'application/json', 'accept': 'application/json' }, - body: JSON.stringify({ - workspaceId: resolveWorkspaceId(document.body) || undefined, - rootUri: rootUri, - query: fallbackQuery, - mode: 'mix', - topK: 12, - chunkTopK: 24, - includeChunkContent: true - }) - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok) return; - var citations = pageAiCollectKnowledgeRagApiCitations(payload); - if (!citations.length) return; - var message = pageUiState.pageAiMessages.find(function(item) { - return item.role === 'assistant' && item.runId === runId; - }) || pageUiState.pageAiMessages.filter(function(item) { return item.role === 'assistant'; }).slice(-1)[0]; - if (!message) return; - var hasPrecise = citations.some(function(citation) { return citation.indexOf('来源定位降级') < 0; }); - var baseContent = hasPrecise ? pageAiCleanDegradedCitationNotes(message.content) : String(message.content || ''); - message.content = pageAiAppendCitationSection(baseContent, citations); - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - } catch (error) { - console.warn('MNote Page AI 自动追加知识库引用失败', error); - } - } - - function pageAiFinishStreamingAssistantMessage(runId, finalText, promptText, citations) { - var id = String(runId || pageUiState.pageAiCurrentRunId || ''); - var message = pageUiState.pageAiMessages.find(function(item) { - return item.role === 'assistant' && item.streaming === true && item.runId === id; - }); - var text = String(finalText || (message && message.content) || ''); - var hasPreciseCitation = pageAiNormalizeArray(citations).some(function(citation) { - return String(citation || '').indexOf('来源定位降级') < 0; - }); - var contentBase = humanizePageAiResponse(text, promptText); - if (hasPreciseCitation) contentBase = pageAiCleanDegradedCitationNotes(contentBase); - var content = pageAiAppendCitationSection(contentBase, citations); - if (message) { - message.content = content; - message.streaming = false; - } else if (content) { - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: content, - runId: id - }); - } - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - void pageAiAppendKnowledgeRagFallbackCitations(id, promptText); - } - - function pageAiStopStreamingAssistantMessage(runId) { - var id = String(runId || pageUiState.pageAiCurrentRunId || ''); - var changed = false; - pageUiState.pageAiMessages.forEach(function(message) { - if (message && message.role === 'assistant' && message.streaming === true && (!id || message.runId === id)) { - message.streaming = false; - changed = true; - } - }); - if (!changed) return; - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - } - - function pageAiToolOutputText(value) { - var parts = []; - function visit(node) { - if (node == null) return; - if (typeof node === 'string') { - parts.push(node); - return; - } - if (typeof node === 'number' || typeof node === 'boolean') return; - if (Array.isArray(node)) { - node.forEach(visit); - return; - } - if (typeof node !== 'object') return; - if (typeof node.text === 'string') parts.push(node.text); - if (typeof node.content === 'string') parts.push(node.content); - if (node.content && typeof node.content === 'object') visit(node.content); - if (node.output && typeof node.output === 'object') visit(node.output); - if (node.result && typeof node.result === 'object') visit(node.result); - } - visit(value); - return parts.join('\n'); - } - - function pageAiParseJsonMaybe(value) { - if (value && typeof value === 'object') return value; - var text = String(value || '').trim(); - if (!text || (text[0] !== '{' && text[0] !== '[')) return null; - try { - return JSON.parse(text); - } catch (_error) { - var firstLine = text.split('\n')[0].trim(); - if (firstLine && firstLine !== text && (firstLine[0] === '{' || firstLine[0] === '[')) { - try { - return JSON.parse(firstLine); - } catch (_lineError) {} - } - return null; - } - } - - function pageAiCollectCitationMarkdowns(value) { - var citations = []; - var seen = {}; - function add(value) { - var citation = String(value || '').trim(); - if (!citation || seen[citation]) return; - seen[citation] = true; - citations.push(citation); - } - function visit(node) { - if (node == null) return; - if (typeof node === 'string') { - var parsed = pageAiParseJsonMaybe(node); - if (parsed) visit(parsed); - return; - } - if (Array.isArray(node)) { - node.forEach(visit); - return; - } - if (typeof node !== 'object') return; - if (typeof node.citationMarkdown === 'string') add(pageAiCitationDisplayMarkdown(node)); - if (Array.isArray(node.citationMarkdowns)) { - node.citationMarkdowns.forEach(function(value) { - if (typeof value === 'string') add(value); - else if (value && typeof value === 'object') add(pageAiCitationDisplayMarkdown(value)); - else visit(value); - }); - } - Object.keys(node).forEach(function(key) { - visit(node[key]); - }); - } - visit(value); - return citations; - } - - function pageAiCollectKnowledgeRagCitations(toolItem, toolEvent) { - var toolName = String((toolItem && toolItem.toolName) || (toolEvent && (toolEvent.name || toolEvent.tool || toolEvent.toolName)) || ''); - var sources = [ - toolEvent, - toolItem, - toolEvent && toolEvent.output, - toolEvent && toolEvent.result, - toolEvent && toolEvent.summary, - toolItem && toolItem.rawOutput, - toolItem && toolItem.rawResult, - toolItem && toolItem.resultSummary - ]; - var citations = []; - var outputText = ''; - sources.forEach(function(source) { - citations = citations.concat(pageAiCollectCitationMarkdowns(source)); - var sourceText = pageAiToolOutputText(source); - if (sourceText) outputText += '\n' + sourceText; - var parsed = pageAiParseJsonMaybe(sourceText); - if (parsed) citations = citations.concat(pageAiCollectCitationMarkdowns(parsed)); - }); - var looksLikeKnowledgeRag = toolName.indexOf('knowledge_rag') >= 0 || - toolName.indexOf('knowledge.rag') >= 0 || - outputText.indexOf('mnote.knowledge_rag') >= 0 || - outputText.indexOf('uiCitations') >= 0; - if (!looksLikeKnowledgeRag && citations.length === 0) return []; - var hasPreciseCitation = citations.some(function(value) { - return String(value || '').indexOf('来源定位降级') < 0; - }); - var seen = {}; - return citations.filter(function(value) { - var citation = String(value || '').trim(); - if (!citation || seen[citation]) return false; - if (hasPreciseCitation && citation.indexOf('来源定位降级') >= 0) return false; - seen[citation] = true; - return true; - }); - } - - function pageAiJournalPayloadText(event) { - try { - return JSON.stringify(event && event.payload && typeof event.payload === 'object' ? event.payload : {}); - } catch (_) { - return '{}'; - } - } - - function pageAiLastUserPrompt() { - var messages = pageAiNormalizeArray(pageUiState.pageAiMessages); - for (var index = messages.length - 1; index >= 0; index -= 1) { - if (messages[index] && messages[index].role === 'user') return String(messages[index].content || ''); - } - return ''; - } - - function pageAiApplyRunJournalEvent(event, runId, runTraceId) { - if (!event || typeof event !== 'object') return; - var eventName = String(event.kind || event.eventType || event.event || '').trim(); - var payload = event.payload && typeof event.payload === 'object' ? event.payload : {}; - if (payload.source === 'adapter_replay' || payload.replay === true) { - document.documentElement.setAttribute('data-mnote-page-ai-adapter-replay-seen', 'true'); - return; - } - var payloadText = pageAiJournalPayloadText(event); - var prompt = pageAiLastUserPrompt(); - if (eventName === 'message.delta') { - pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText)); - } else if (eventName === 'run.completed') { - pageAiFinishStreamingAssistantMessage(runId, String(payload.output || ''), prompt, []); - pageAiSetRunStatus('completed', runId); - } else if (eventName === 'run.failed') { - pageAiFinishStreamingAssistantMessage(runId, String(payload.message || payload.code || payload.error || 'AI run failed'), prompt, []); - pageAiSetRunStatus('failed', runId); - } else if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') { - pageUiState.pageAiStoppedRunIds[runId] = true; - pageAiStopStreamingAssistantMessage(runId); - pageAiSetRunStatus('aborted', runId); - } else if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { - var toolItem = pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId); - if (eventName === 'tool.completed') { - var citations = pageAiCollectKnowledgeRagCitations(toolItem, payload); - if (citations.length) pageAiFinishStreamingAssistantMessage(runId, '', prompt, citations); - } - pageAiSetRunStatus('tool_calling', runId); - } else if (eventName === 'plan.updated') { - var planEntries = Array.isArray(payload.entries) ? payload.entries : []; - if (planEntries.length) { - pageUiState.pageAiMessages.push({ role: 'system', kind: 'plan', entries: planEntries, createdAt: Date.now(), updatedAt: Date.now() }); - } - } - var seq = String(event.seq || '').trim(); - if (seq) { - pageAiWriteActiveRunSnapshot({ - hostRunId: runId, - sessionId: pageUiState.pageAiActiveSessionId || String(event.sessionId || ''), - lastSeq: seq, - status: pageUiState.pageAiRunStatus || 'running' - }); - document.documentElement.setAttribute('data-mnote-page-ai-active-run-last-seq', seq); - } - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - } - - async function pageAiResumeActiveRunJournal(run) { - if (!run || typeof run !== 'object') return; - var runId = String(run.hostRunId || run.runId || '').trim(); - if (!runId) return; - var snapshot = pageAiReadActiveRunSnapshot(); - var afterSeq = snapshot && snapshot.hostRunId === runId ? String(snapshot.lastSeq || '000000000000000000') : '000000000000000000'; - var response = await fetch('/api/page-ai/runs/' + encodeURIComponent(runId) + '/events?afterSeq=' + encodeURIComponent(afterSeq), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'run_journal_failed_' + response.status)); - } - var runTraceId = String((payload.run && payload.run.traceId) || (run && run.traceId) || ''); - pageAiNormalizeArray(payload.events).forEach(function(event) { - pageAiApplyRunJournalEvent(event, runId, runTraceId); - }); - var status = String((payload.run && payload.run.status) || run.status || '').trim(); - if (pageAiRunStatusIsTerminal(status)) pageAiClearActiveRunSnapshot(runId); - document.documentElement.setAttribute('data-mnote-page-ai-run-journal-resumed', runId); - } - - function pageAiLooksLikeBlockEdit(prompt) { - var text = searchText(prompt); - return ['新增', '添加', '插入', '删除', '删掉', '修改', '替换', '改成', '移动', '移到', 'move', 'replace', 'delete', 'insert'].some(function(word) { - return text.indexOf(word) >= 0; - }); - } - - function assertPageAiLocalWritePermission(prompt, targetPackage) { - if (currentSourceKind() !== 'local_folder') return; - if (!pageAiLooksLikeBlockEdit(prompt)) return; - var target = pageAiNormalizeArray(targetPackage && targetPackage.targets)[0] || null; - var permission = String(target && target.policy && target.policy.permission || '').trim(); - if (permission === 'read_write') return; - var error = new Error('当前本地工作区只读授权,不能让 AI 写入目标文件。请切换到有写权限的工作区或调整授权后重试。'); - error.code = 'page_ai_target_readonly'; - throw error; - } - - async function pageAiTryBlockEditWorkflow(prompt, scopedContext, runTargetSnapshot) { - if (currentSourceKind() === 'local_folder') return false; - if (!pageAiLooksLikeBlockEdit(prompt)) return false; - var runId = 'page-ai-fast-' + Date.now().toString(36); - var traceId = 'page-ai-fast-' + Date.now().toString(36); - pageAiSetRunStatus('running', runId); - renderPageAiControls(); - var started = Date.now(); - var response = await fetch('/api/page-ai/block-edit-workflow', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: resolveWorkspaceId(document.body), - documentId: currentDocumentId(), - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - sessionId: pageUiState.pageAiActiveSessionId, - runId: runId, - profile: pageAiCurrentProfile(), - model: pageAiMnoteToolModel(), - message: prompt, - pageContext: scopedContext.pageContext, - editorTarget: scopedContext.editorTarget, - runTargetSnapshot: runTargetSnapshot || null, - selectedBlockId: scopedContext.selectedBlockId, - selectedText: scopedContext.selectedText, - traceId: traceId - }) - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok || !payload || payload.ok !== true) { - var code = payload && payload.code ? String(payload.code) : ''; - if (code === 'page_ai_workflow_not_block_edit') return false; - pageUiState.pageAiMessages.push({ - role: 'tool', - toolName: 'mnote.page_ai.block_edit_workflow', - status: 'failed', - toolCallId: runId, - resultSummary: pageAiErrorMessage(payload, 'fast_path_failed_' + response.status) - }); - renderPageAiConversation(); - pageAiSetRunStatus('failed', runId); - renderPageAiControls(); - return true; - } - pageUiState.pageAiMessages.push({ - role: 'tool', - toolName: 'mnote.page_ai.block_edit_workflow', - status: 'completed', - toolCallId: runId, - resultSummary: 'operations=' + String(Array.isArray(payload.operations) ? payload.operations.length : 0) + ' · ' + String(Date.now() - started) + 'ms' - }); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: payload.message || '已通过页面块编辑快路径完成写入。' - }); - pageAiSetRunStatus('completed', runId); - try { - window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { - detail: { - toolName: 'mnote.doc.apply_block_ops', - normalizedToolName: 'mnote.doc.apply.block.ops', - documentId: currentDocumentId(), - workspaceId: resolveWorkspaceId(document.body), - runId: runId, - traceId: traceId, - toolCallId: runId - } - })); - } catch (_) {} - var currentSession = pageAiCurrentSession(); - if (currentSession) { - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - return true; - } - - async function sendPageAiMessage(text) { - if (pageAiBoardFirstEnabled()) { - await sendPageAiBoardMessage(text); - return; - } - var allowQueue = pageUiState.pageAiBusy - || ['queued', 'running', 'tool_calling', 'pending', 'acp_pending'].indexOf(pageUiState.pageAiRunStatus) >= 0; - if (pageUiState.pageAiBusy && !allowQueue) return; - var prompt = searchText(text); - if (!prompt) return; - if (allowQueue) { - pageAiEnqueueClientPrompt(prompt); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '已加入 AI 队列,前一条 run 完成后继续处理。' - }); - var queuedSession = pageAiCurrentSession(); - if (queuedSession) { - queuedSession.messages = pageUiState.pageAiMessages.slice(); - queuedSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - return; - } - if (!allowQueue) pageUiState.pageAiBusy = true; - var currentSession = null; - try { - await pageAiEnsureHermesSession(); - if (!allowQueue) pageAiSetRunStatus('queued'); - renderPageAiControls(); - var contextSnapshot = currentPageAiContextSnapshot(); - var scopedContext = pageAiScopedPageContext(contextSnapshot); - scopedContext.editorTarget = currentPageAiScopedEditorTarget(); - assertPageAiTargetInCurrentWorkspace(scopedContext.editorTarget); - if (currentSourceKind() === 'local_folder' && !pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).length) { - await pageAiLoadAllowedRoots(); - } - await assertPageAiTargetWritable(scopedContext.editorTarget); - var runTargetSnapshot = pageAiBuildRunTargetSnapshot(scopedContext, prompt); - var contextRefs = pageAiBuildContextRefs(scopedContext, runTargetSnapshot); - var allowedRoots = pageAiBuildAllowedRoots(); - var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot); - assertPageAiLocalWritePermission(prompt, agentTargetPackage); - if (scopedContext.pageContext && scopedContext.pageContext.aiContext) { - scopedContext.pageContext.aiContext.runTargetSnapshot = runTargetSnapshot; - scopedContext.pageContext.aiContext.agentTargetPackage = agentTargetPackage; - } - var requestPageContext = pageAiPageContextForRefs(scopedContext.pageContext, contextRefs); - pageUiState.pageAiMessages.push({ role: 'user', content: prompt }); - currentSession = pageAiCurrentSession(); - var runProfile = pageAiRunProfile(); - if (currentSession) { - if (currentSession.title === '新会话') { - currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt; - } - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); - if (!allowQueue && await pageAiTryBlockEditWorkflow(prompt, scopedContext, runTargetSnapshot)) { - return; - } - var reasonixSettings = pageAiCurrentAgentId() === 'reasonix' ? pageAiReasonixControlState() : null; - var promptForRun = pageAiApplyReasonixPlanMode(prompt); - var response = await fetch('/api/hermes/client/runs', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: resolveWorkspaceId(document.body), - documentId: currentDocumentId(), - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - agentId: pageAiCurrentAgentId(), - sessionId: pageUiState.pageAiActiveSessionId, - acpSessionId: currentSession && currentSession.acpSessionId ? currentSession.acpSessionId : '', - profile: runProfile, - profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '', - acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - contextScope: pageUiState.pageAiContextScope, - contextRefs: contextRefs, - allowedRoots: allowedRoots, - reasonixSettings: reasonixSettings, - skillPreferences: { - mnote: pageAiSkillPreferenceTable('mnote', ''), - reasonix: pageAiSkillPreferenceTable('reasonix', ''), - hermes: pageAiSkillPreferenceTable('hermes', runProfile) - }, - message: promptForRun, - model: reasonixSettings && reasonixSettings.modelId ? reasonixSettings.modelId : pageAiMnoteToolModel(), - pageContext: requestPageContext, - editorTarget: scopedContext.editorTarget, - runTargetSnapshot: runTargetSnapshot, - targetPackage: agentTargetPackage, - selectedBlockId: scopedContext.selectedBlockId, - selectedText: scopedContext.selectedText || '', - traceId: 'page-ai-run-' + Date.now().toString(36) - }) - }); - if (!response.ok) { - var errorPayload = await response.json().catch(function(){ return null; }); - throw new Error(pageAiErrorMessage(errorPayload, 'page_ai_failed_' + response.status)); - } - var runPayload = await response.json().catch(function(){ return null; }); - if (pageAiApplyQueuedRun(runPayload)) { - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '已加入 AI 队列,前一条 run 完成后继续处理。' - }); - if (currentSession) { - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - void pageAiLoadRuntimeStatus().catch(function(error) { - pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - return; - } - if (allowQueue) { - throw new Error('hermes_queue_expected_queued_response'); - } - var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload; - var runId = upstream && (upstream.run_id || upstream.runId); - if (!runId) throw new Error('hermes_run_missing_run_id'); - var runTraceId = String((upstream && (upstream.trace_id || upstream.traceId)) || (runPayload && (runPayload.trace_id || runPayload.traceId)) || ''); - pageAiSetRunStatus('running', runId); - pageAiSetRunTargetSnapshot(Object.assign({}, runTargetSnapshot, { - runId: runId, - sessionId: pageUiState.pageAiActiveSessionId, - traceId: runTraceId - })); - renderPageAiControls(); - var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), { - headers: { 'accept': 'text/event-stream' } - }); - if (!eventResponse.ok) { - var eventError = await eventResponse.json().catch(function(){ return null; }); - throw new Error(pageAiErrorMessage(eventError, 'hermes_events_failed_' + eventResponse.status)); - } - var assistantText = ''; - var autoCitations = []; - await streamPageAiResponse(eventResponse, function(eventName, payloadText) { - pageAiTrackStreamingRunEvent(runId, eventName, payloadText); - var streamPayload = null; - try { - streamPayload = JSON.parse(payloadText || 'null'); - } catch (_) {} - if (streamPayload && (streamPayload.source === 'adapter_replay' || streamPayload.replay === true)) { - document.documentElement.setAttribute('data-mnote-page-ai-adapter-replay-seen', 'true'); - return; - } - if (eventName === 'message.delta') { - if (pageUiState.pageAiStoppedRunIds[runId]) return; - assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText)); - } - if (eventName === 'thought.delta') { - try { - var thoughtPayload = JSON.parse(payloadText || 'null'); - var thoughtText = String((thoughtPayload && (thoughtPayload.delta || thoughtPayload.text)) || ''); - if (thoughtText) { - var msgs = pageUiState.pageAiMessages; - var lastThought = msgs.length > 0 && msgs[msgs.length - 1].kind === 'thought' ? msgs[msgs.length - 1] : null; - if (lastThought) { - lastThought.content += thoughtText; - } else { - msgs.push({ role: 'assistant', kind: 'thought', content: thoughtText }); - } - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - } - } catch (_) {} - } - if (eventName === 'usage.updated') { - try { - var usagePayload = JSON.parse(payloadText || 'null') || {}; - var sessionForUsage = pageAiCurrentSession(); - if (sessionForUsage) { - sessionForUsage.usage = { - source: 'usage_update', - used: Number(usagePayload.used ?? usagePayload.contextUsed ?? 0), - size: Number(usagePayload.size ?? usagePayload.contextSize ?? 0) - }; - pageAiPersistSessions(); - } - } catch (_) {} - renderPageAiControls(); - } - if (eventName === 'permission.requested' || eventName === 'permission.denied' || eventName === 'permission.allowed') { - pageAiApplyPermissionEvent(eventName, payloadText); - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - } - if (eventName === 'run.completed') { - try { - var completed = JSON.parse(payloadText || 'null'); - if (completed && completed.output) assistantText = String(completed.output || ''); - if (completed && completed.usage) { - var completedSession = pageAiCurrentSession(); - if (completedSession) completedSession.usage = completed.usage; - } - var agentAudit = completed && completed.agentAudit && typeof completed.agentAudit === 'object' ? completed.agentAudit : null; - var agentRunReceipt = agentAudit && agentAudit.agentRunReceipt && typeof agentAudit.agentRunReceipt === 'object' - ? agentAudit.agentRunReceipt - : null; - var changedFiles = pageAiNormalizeArray(agentAudit && agentAudit.changedFiles).map(function(file) { - if (!file || typeof file !== 'object') return file; - return Object.assign({ - actorId: String(agentAudit && agentAudit.actorId || ''), - actorType: String(agentAudit && agentAudit.actorType || ''), - agentKind: String(agentAudit && agentAudit.agentKind || ''), - runId: runId - }, file); - }); - if (changedFiles.length) { - pageUiState.pageAiMessages.push({ - role: 'tool', - content: 'agent.changed_files', - toolCallId: runId + ':agent.changed_files', - toolName: 'agent.changed_files', - toolKind: 'audit', - status: 'completed', - argsSummary: [agentAudit.rootUri, agentAudit.actorId, agentAudit.agentKind].map(function(value) { - return String(value || '').trim(); - }).filter(Boolean).join(' · '), - resultSummary: String(agentAudit.diffSummary || changedFiles.length + ' changed file(s)'), - changedFiles: changedFiles, - traceId: runTraceId, - auditId: String(agentAudit.eventId || '') - }); - } - if (agentRunReceipt || changedFiles.length) { - try { - pageAiDispatchReceiptRefresh(agentRunReceipt, changedFiles, agentAudit, runId, runTraceId); - } catch (_) {} - } - } catch (_) {} - pageAiSetRunStatus('completed', runId); - } - if (eventName === 'run.failed') { - try { - var failed = JSON.parse(payloadText || 'null'); - assistantText = String((failed && (failed.message || failed.code || failed.error)) || 'AI run failed'); - } catch (_) {} - pageAiSetRunStatus('failed', runId); - } - if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') { - pageUiState.pageAiStoppedRunIds[runId] = true; - pageAiStopStreamingAssistantMessage(runId); - pageAiSetRunStatus('aborted', runId); - } - if (eventName === 'session.info.updated') { - try { - var infoPayload = JSON.parse(payloadText || 'null') || {}; - var newTitle = String(infoPayload.title || '').trim(); - var acpSessionId = String(infoPayload.acpSessionId || infoPayload.acp_session_id || '').trim(); - var sessionForInfo = pageAiCurrentSession(); - if (acpSessionId && sessionForInfo) { - sessionForInfo.acpSessionId = acpSessionId; - sessionForInfo.updatedAt = Date.now(); - pageAiPersistSessions(); - } - if (newTitle) { - var sessionForTitle = sessionForInfo || pageAiCurrentSession(); - if (sessionForTitle) { - sessionForTitle.title = newTitle; - sessionForTitle.updatedAt = Date.now(); - pageAiPersistSessions(); - renderPageAiControls(); - } - } - } catch (_) {} - } - if (eventName === 'plan.updated') { - try { - var planPayload = JSON.parse(payloadText || 'null') || {}; - var planEntries = Array.isArray(planPayload.entries) ? planPayload.entries : []; - if (planEntries.length) { - var planMsgs = pageUiState.pageAiMessages; - var existingPlan = planMsgs.length > 0 && planMsgs[planMsgs.length - 1].kind === 'plan' ? planMsgs[planMsgs.length - 1] : null; - if (existingPlan) { - existingPlan.entries = planEntries; - existingPlan.updatedAt = Date.now(); - } else { - planMsgs.push({ role: 'system', kind: 'plan', entries: planEntries, createdAt: Date.now(), updatedAt: Date.now() }); - } - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - } - } catch (_) {} - } - if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { - var toolEventPayload = null; - try { - toolEventPayload = JSON.parse(payloadText || 'null'); - } catch (_) {} - var toolItem = pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId); - if (eventName === 'tool.completed') { - var completedCitations = pageAiCollectKnowledgeRagCitations(toolItem, toolEventPayload); - if (completedCitations.length) autoCitations = completedCitations; - } - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - pageAiSetRunStatus('tool_calling', runId); - renderPageAiControls(); - } - }); - if (!pageUiState.pageAiStoppedRunIds[runId]) { - pageAiFinishStreamingAssistantMessage(runId, assistantText, prompt, autoCitations); - } - currentSession = pageAiCurrentSession(); - if (currentSession) { - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - } catch (error) { - pageAiSetRunStatus('failed'); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: 'AI 当前请求失败:' + (error instanceof Error ? error.message : String(error)) - }); - currentSession = pageAiCurrentSession(); - if (currentSession) { - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - } finally { - if (!allowQueue) pageUiState.pageAiBusy = false; - await pageAiLoadRuntimeStatus().catch(function(error) { - pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error); - return null; - }); - renderPageAiConversation(); - renderPageAiControls(); - pageAiStartNextClientQueuedPrompt(); - } - } - - function handlePageAiClick(event, helpers) { - var closestAction = helpers && helpers.closestAction; - if (typeof closestAction !== 'function') return false; - var pageAiCitationLink = closestAction(event.target, 'a[data-page-ai-citation-link="true"]'); - if (pageAiCitationLink && !(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { - var href = pageAiCitationLink.getAttribute('href') || ''; - if (pageAiOpenCitationUrl(href)) { - event.preventDefault(); - return true; - } - } - var pageAiClose = closestAction(event.target, '[data-page-ai-action="close"]'); - if (pageAiClose) { - event.preventDefault(); - closePageAiDrawer(); - return true; - } - var pageAiOpencodeFile = closestAction(event.target, '[data-page-ai-opencode-open-file]'); - if (pageAiOpencodeFile) { - event.preventDefault(); - pageAiOpenOpencodeChangedFile(pageAiOpencodeFile.getAttribute('data-page-ai-opencode-open-file') || ''); - return true; - } - var pageAiSettings = closestAction(event.target, '[data-page-ai-action="open-hermes-settings"]'); - if (pageAiSettings) { - event.preventDefault(); - pageAiOpenHermesSettings(); - return true; - } - var pageAiStop = closestAction(event.target, '[data-page-ai-action="stop-run"]'); - if (pageAiStop) { - event.preventDefault(); - void pageAiStopRun(); - return true; - } - var pageAiReset = closestAction(event.target, '[data-page-ai-action="reset-runtime"]'); - if (pageAiReset) { - event.preventDefault(); - void pageAiResetRuntime(); - return true; - } - var pageAiStopAllJobs = closestAction(event.target, '[data-page-ai-action="stop-all-jobs"]'); - if (pageAiStopAllJobs) { - event.preventDefault(); - pageUiState.pageAiMessages.push({ role: 'assistant', content: '当前没有可停止的后台 job;长任务 job 将在 runtime status 中独立显示。' }); - renderPageAiConversation(); - renderPageAiControls(); - return true; - } - var pageAiRotate = closestAction(event.target, '[data-page-ai-action="rotate"]'); - if (pageAiRotate) { - event.preventDefault(); - pageUiState.pageAiSuggestionIndex += 1; - renderPageAiSuggestions(); - return true; - } - var pageAiIntent = closestAction(event.target, '[data-page-ai-intent]'); - if (pageAiIntent) { - event.preventDefault(); - var intentName = pageAiIntent.getAttribute('data-page-ai-intent') || ''; - if (intentName === 'create-summary') void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_summary,为当前页面创建或更新 AI Summary。'); - if (intentName === 'create-ai-note') void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_ai_note,基于当前页面创建一篇新的 AI Note。'); - return true; - } - var pageAiTab = closestAction(event.target, '[data-page-ai-tab]'); - if (pageAiTab) { - event.preventDefault(); - pageUiState.pageAiPage = pageAiTab.getAttribute('data-page-ai-tab') || 'chat'; - if (pageUiState.pageAiPage === 'runtime') pageUiState.pageAiPage = 'status'; - if (pageUiState.pageAiPage === 'chat-only-settings') pageUiState.pageAiPage = 'agent'; - if (pageUiState.pageAiPage === 'status') { - void pageAiLoadGatewayHealth(); - void pageAiLoadRuntimeStatus().catch(function(error) { - pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - } - if (pageUiState.pageAiPage === 'skills') void pageAiLoadSkills(); - renderPageAiControls(); - renderPageAiConversation(); - return true; - } - var pageAiProvider = closestAction(event.target, '[data-page-ai-provider]'); - if (pageAiProvider) { - event.preventDefault(); - pageUiState.pageAiProvider = pageAiProvider.getAttribute('data-page-ai-provider') || 'hermes'; - renderPageAiProviderButtons(); - return true; - } - var pageAiAgent = closestAction(event.target, '[data-page-ai-agent-id]'); - if (pageAiAgent) { - event.preventDefault(); - var nextAgentId = pageAiAgent.getAttribute('data-page-ai-agent-id') || 'reasonix'; - var nextProfileId = pageAiAgent.getAttribute('data-page-ai-profile-id') || ''; - pageAiSetAgentId(nextAgentId); - if (nextProfileId) { - pageUiState.pageAiAgentPopoverOpen = false; - void pageAiSwitchProfile(nextProfileId); - renderPageAiControls(); - } - return true; - } - var pageAiAgentButton = closestAction(event.target, '[data-page-ai-agent-button]'); - if (pageAiAgentButton) { - event.preventDefault(); - pageAiSetAgentPopoverOpen(pageAiAgentButton.getAttribute('aria-expanded') !== 'true'); - return true; - } - var pageAiContextButton = closestAction(event.target, '[data-page-ai-context-button]'); - if (pageAiContextButton) { - event.preventDefault(); - pageAiSetContextPopoverOpen(pageAiContextButton.getAttribute('aria-expanded') !== 'true'); - return true; - } - var pageAiTargetButton = closestAction(event.target, '[data-page-ai-target-button]'); - if (pageAiTargetButton) { - event.preventDefault(); - pageAiSetTargetPopoverOpen(pageAiTargetButton.getAttribute('aria-expanded') !== 'true'); - return true; - } - var pageAiAction = closestAction(event.target, '[data-page-ai-action]'); - if (pageAiAction) { - event.preventDefault(); - var action = pageAiAction.getAttribute('data-page-ai-action') || ''; - if (action === 'close-agent-popover') pageAiSetAgentPopoverOpen(false); - if (action === 'close-context-popover') pageAiSetContextPopoverOpen(false); - if (action === 'close-target-popover') pageAiSetTargetPopoverOpen(false); - if (action === 'new-session') { - pageUiState.pageAiPage = 'chat'; - pageAiStartNewSession(); - } - if (action === 'history') { - pageAiLoadSessions(); - pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history'; - renderPageAiControls(); - renderPageAiConversation(); - if (pageUiState.pageAiPage === 'history') { - void pageAiLoadBackendSessions().catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - } - } - if (action === 'send') { - var input = ensurePageAiDrawer().querySelector('[data-page-ai-input]'); - if (input instanceof HTMLTextAreaElement) { - var message = input.value; - input.value = ''; - void sendPageAiMessage(message); - } - } - if (action === 'opencode-send') { - var opencodeInput = ensurePageAiDrawer().querySelector('[data-page-ai-opencode-input]'); - var opencodeText = opencodeInput instanceof HTMLTextAreaElement ? opencodeInput.value.trim() : ''; - if (opencodeText) { - opencodeInput.value = ''; - void pageAiSendOpencodeMessage(opencodeText).catch(function(error) { - pageUiState.pageAiOpencodeStatusError = error instanceof Error ? error.message : String(error); - pageUiState.pageAiOpencodeSending = false; - pageAiRenderOpencodeHostChrome(); - }); - } - } - if (action === 'cancel-queued-run') { - void pageAiCancelQueuedRun(pageAiAction.getAttribute('data-page-ai-queue-id')); - } - if (action === 'opencode-refresh-status') { - void pageAiLoadOpencodeStatus(); - } - if (action === 'opencode-new-session') { - pageUiState.pageAiOpencodeSessionId = ''; - pageUiState.pageAiOpencodeBindingKey = ''; - pageUiState.pageAiOpencodeMessages = []; - pageUiState.pageAiOpencodeLocalMessages = []; - void pageAiEnsureOpencodeSessionBinding(true); - } - if (action === 'opencode-abort') { - void pageAiAbortOpencodeRun(); - } - if (action === 'opencode-refresh-current-page') { - pageAiRefreshOpencodeCurrentPage(); - } - if (action === 'opencode-insert-context') { - pageAiPostOpencodeBridgeMessage('insert-text', { text: pageAiOpencodeContextTextForInsert() }); - } - return true; - } - var pageAiSessionSelect = closestAction(event.target, '[data-page-ai-opencode-session-select]'); - if (pageAiSessionSelect) { - event.preventDefault(); - pageAiSelectOpencodeSession(pageAiSessionSelect.getAttribute('data-page-ai-opencode-session-select') || ''); - return true; - } - var pageAiPermission = closestAction(event.target, '[data-page-ai-opencode-permission]'); - if (pageAiPermission) { - event.preventDefault(); - void pageAiReplyOpencodePermission(pageAiPermission.getAttribute('data-page-ai-opencode-permission-id') || '', pageAiPermission.getAttribute('data-page-ai-opencode-permission') || 'once'); - return true; - } - var pageAiTargetOption = closestAction(event.target, '[data-page-ai-target-option]'); - if (pageAiTargetOption) { - event.preventDefault(); - pageAiSelectTarget(pageAiTargetOption.getAttribute('data-page-ai-target-option') || ''); - return true; - } - var pageAiContextRef = closestAction(event.target, '[data-page-ai-context-ref]'); - if (pageAiContextRef) { - event.preventDefault(); - pageAiToggleContextRef(pageAiContextRef.getAttribute('data-page-ai-context-ref') || ''); - return true; - } - var pageAiMemorySave = closestAction(event.target, '[data-page-ai-memory-save]'); - if (pageAiMemorySave) { - event.preventDefault(); - void pageAiSaveProfileMemory(pageAiMemorySave.getAttribute('data-page-ai-memory-save') || ''); - return true; - } - var pageAiSkillToggle = closestAction(event.target, '[data-page-ai-skill-toggle]'); - if (pageAiSkillToggle) { - event.preventDefault(); - void pageAiToggleSkill( - pageAiSkillToggle.getAttribute('data-page-ai-skill-toggle') || '', - pageAiSkillToggle.getAttribute('aria-pressed') !== 'true', - pageAiSkillToggle.getAttribute('data-page-ai-skill-group') || '', - pageAiSkillToggle.getAttribute('data-page-ai-skill-profile') || '' - ); - return true; - } - var pageAiSkillGroupToggle = closestAction(event.target, '[data-page-ai-skill-group-toggle]'); - if (pageAiSkillGroupToggle) { - event.preventDefault(); - pageAiToggleSkillGroup(pageAiSkillGroupToggle.getAttribute('data-page-ai-skill-group-toggle') || ''); - return true; - } - var pageAiToolToggle = closestAction(event.target, '[data-page-ai-tool-toggle]'); - if (pageAiToolToggle) { - event.preventDefault(); - void pageAiToggleTool(pageAiToolToggle.getAttribute('data-page-ai-tool-toggle') || '', pageAiToolToggle.getAttribute('aria-pressed') !== 'true'); - return true; - } - var pageAiSessionSelect = closestAction(event.target, '[data-page-ai-session-select]'); - if (pageAiSessionSelect) { - event.preventDefault(); - pageAiToggleSessionSelection( - pageAiSessionSelect.getAttribute('data-page-ai-session-select') || '', - pageAiSessionSelect.getAttribute('aria-checked') !== 'true' - ); - return true; - } - var pageAiClearSelectedSessions = closestAction(event.target, '[data-page-ai-session-clear-selection]'); - if (pageAiClearSelectedSessions) { - event.preventDefault(); - pageAiClearSessionSelection(); - return true; - } - var pageAiDeleteSelectedSessions = closestAction(event.target, '[data-page-ai-session-delete-selected]'); - if (pageAiDeleteSelectedSessions) { - event.preventDefault(); - void pageAiDeleteSelectedBackendSessions().catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - return true; - } - var pageAiSessionResume = closestAction(event.target, '[data-page-ai-session-resume]'); - if (pageAiSessionResume) { - event.preventDefault(); - void pageAiResumeBackendSession(pageAiSessionResume.getAttribute('data-page-ai-session-resume') || '').catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - return true; - } - var pageAiSessionRename = closestAction(event.target, '[data-page-ai-session-rename]'); - if (pageAiSessionRename) { - event.preventDefault(); - void pageAiRenameBackendSession(pageAiSessionRename.getAttribute('data-page-ai-session-rename') || '').catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - return true; - } - var pageAiSessionExport = closestAction(event.target, '[data-page-ai-session-export]'); - if (pageAiSessionExport) { - event.preventDefault(); - void pageAiExportBackendSession(pageAiSessionExport.getAttribute('data-page-ai-session-export') || '').catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - return true; - } - var pageAiSessionDelete = closestAction(event.target, '[data-page-ai-session-delete]'); - if (pageAiSessionDelete) { - event.preventDefault(); - void pageAiDeleteBackendSession(pageAiSessionDelete.getAttribute('data-page-ai-session-delete') || '').catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - return true; - } - var pageAiPermissionAction = closestAction(event.target, '[data-page-ai-permission-action]'); - if (pageAiPermissionAction) { - event.preventDefault(); - pageAiResolvePermission( - pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '', - pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny', - pageAiPermissionAction.getAttribute('data-page-ai-permission-option-id') || '' - ); - return true; - } - var pageAiPlanAction = closestAction(event.target, '[data-page-ai-plan-action]'); - if (pageAiPlanAction) { - event.preventDefault(); - var action = String(pageAiPlanAction.getAttribute('data-page-ai-plan-action') || '').trim(); - var inputNode = ensurePageAiDrawer().querySelector('[data-page-ai-input]'); - if (action === 'approve') { - void sendPageAiMessage('批准,开始执行。'); - return true; - } - if (action === 'cancel') { - void sendPageAiMessage('取消执行。'); - return true; - } - if (inputNode instanceof HTMLTextAreaElement) { - inputNode.value = action === 'revise' ? '请修改计划:' : ''; - inputNode.focus(); - } - return true; - } - var pageAiOpenLocationAction = closestAction(event.target, '[data-page-ai-open-location]'); - if (pageAiOpenLocationAction) { - event.preventDefault(); - var loc = String(pageAiOpenLocationAction.getAttribute('data-page-ai-open-location') || '').trim(); - if (loc) pageAiOpenLocation(loc); - return true; - } - var pageAiBoardRunDetail = closestAction(event.target, '[data-page-ai-board-run-detail]'); - if (pageAiBoardRunDetail) { - event.preventDefault(); - pageUiState.pageAiBoardActiveDetailRunId = pageAiBoardRunDetail.getAttribute('data-page-ai-board-run-detail') || ''; - renderPageAiConversation(); - return true; - } - var pageAiCopyMessage = closestAction(event.target, '[data-page-ai-copy-message]'); - if (pageAiCopyMessage) { - event.preventDefault(); - var runId = pageAiCopyMessage.getAttribute('data-page-ai-copy-message') || ''; - var message = pageAiNormalizeArray(pageUiState.pageAiMessages).find(function(item) { return String(item && item.boardRunId || '') === runId; }); - if (navigator.clipboard && message) void navigator.clipboard.writeText(String(message.content || '')); - return true; - } - var pageAiRetryMessage = closestAction(event.target, '[data-page-ai-retry-message]'); - if (pageAiRetryMessage) { - event.preventDefault(); - var retryRunId = pageAiRetryMessage.getAttribute('data-page-ai-retry-message') || ''; - var retryMessage = pageAiNormalizeArray(pageUiState.pageAiMessages).find(function(item) { return String(item && item.boardRunId || '') === retryRunId; }); - var retryPrompt = String(retryMessage && retryMessage.retryPrompt || '').trim(); - if (retryPrompt) void sendPageAiMessage(retryPrompt); - return true; - } - var pageAiSession = closestAction(event.target, '[data-page-ai-session]'); - if (pageAiSession) { - event.preventDefault(); - pageAiSetActiveSession(pageAiSession.getAttribute('data-page-ai-session') || ''); - return true; - } - var pageAiSuggestion = closestAction(event.target, '[data-page-ai-suggestion]'); - if (pageAiSuggestion) { - event.preventDefault(); - var text = pageAiSuggestion.getAttribute('data-page-ai-suggestion') || ''; - var inputNode = ensurePageAiDrawer().querySelector('[data-page-ai-input]'); - if (inputNode instanceof HTMLTextAreaElement) { - inputNode.value = text; - inputNode.focus(); - } - return true; - } - return false; - } - - function handlePageAiKeyDown(event, helpers) { - var closestAction = helpers && helpers.closestAction; - if (typeof closestAction !== 'function') return false; - if (event.key === 'Enter' && !event.shiftKey) { - var aiInput = closestAction(event.target, '[data-page-ai-input]'); - if (aiInput instanceof HTMLTextAreaElement) { - event.preventDefault(); - var text = aiInput.value; - aiInput.value = ''; - void sendPageAiMessage(text); - return true; - } - } - return false; - } - - function handlePageAiInput(event, helpers) { - var closestAction = helpers && helpers.closestAction; - if (typeof closestAction !== 'function') return false; - var skillSearch = closestAction(event.target, '[data-page-ai-skill-search]'); - if (skillSearch instanceof HTMLInputElement) { - pageUiState.pageAiSkillQuery = skillSearch.value; - renderPageAiControls(); - return true; - } - var sessionSearch = closestAction(event.target, '[data-page-ai-session-search]'); - if (sessionSearch instanceof HTMLInputElement) { - var sessionQuery = sessionSearch.value; - window.clearTimeout(pageUiState.pageAiSessionSearchTimer || 0); - pageUiState.pageAiSessionSearchTimer = window.setTimeout(function() { - void pageAiSearchBackendSessions(sessionQuery).catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - }, 200); - return true; - } - var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]'); - if (memoryEditor instanceof HTMLTextAreaElement) { - var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || ''; - if (['memory', 'user', 'soul'].indexOf(section) >= 0) pageUiState.pageAiProfileMemoryDrafts[section] = memoryEditor.value; - return true; - } - return false; - } - - function handlePageAiChange(event, helpers) { - var closestAction = helpers && helpers.closestAction; - if (typeof closestAction !== 'function') return false; - var pageAiDescriptorField = closestAction(event.target, '[data-page-ai-descriptor-field]'); - if (pageAiDescriptorField instanceof HTMLInputElement || pageAiDescriptorField instanceof HTMLSelectElement) { - var key = pageAiDescriptorField.getAttribute('data-page-ai-descriptor-field') || ''; - var kind = pageAiDescriptorField.getAttribute('data-page-ai-descriptor-kind') || ''; - var value = kind === 'boolean' && pageAiDescriptorField instanceof HTMLInputElement - ? pageAiDescriptorField.checked - : pageAiDescriptorField.value; - pageAiSetDescriptorFieldValue(key, value); - return true; - } - var pageAiAcpRuntimeSelect = closestAction(event.target, '[data-page-ai-acp-runtime]'); - if (pageAiAcpRuntimeSelect instanceof HTMLSelectElement) { - var next = String(pageAiAcpRuntimeSelect.value || 'reasonix').trim() || 'reasonix'; - pageUiState.pageAiAcpRuntime = next; - pageUiState.pageAiSkills = { categories: [], archived: [] }; - pageUiState.pageAiSkillError = ''; - pageAiPersistSessions(); - void pageAiLoadProfiles(); - if (next !== 'reasonix') void pageAiLoadProfileMemory(); - void pageAiLoadSkills(); - void pageAiLoadBackendSessions().catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - }); - renderPageAiControls(); - renderPageAiProviderButtons(); - return true; - } - var pageAiProfileSelect = closestAction(event.target, '[data-page-ai-profile-select]'); - if (pageAiProfileSelect instanceof HTMLSelectElement) { - void pageAiSwitchProfile(pageAiProfileSelect.value); - return true; - } - var pageAiBoardWorker = closestAction(event.target, '[data-page-ai-board-worker]'); - if (pageAiBoardWorker instanceof HTMLSelectElement) { - pageUiState.pageAiBoardWorkerId = String(pageAiBoardWorker.value || pageAiBoardDefaultWorkerId()).trim() || pageAiBoardDefaultWorkerId(); - pageUiState.pageAiBoardModelOverride = pageAiBoardSelectedModelOverride(); - pageAiPersistAiPreference('board.worker_preset_id', pageUiState.pageAiBoardWorkerId); - pageAiPersistAiPreference('board.model_override', pageUiState.pageAiBoardModelOverride); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - return true; - } - var pageAiBoardModel = closestAction(event.target, '[data-page-ai-board-model]'); - if (pageAiBoardModel instanceof HTMLSelectElement) { - pageUiState.pageAiBoardModelOverride = String(pageAiBoardModel.value || pageAiBoardSelectedModelOverride()).trim() || pageAiBoardSelectedModelOverride(); - pageAiPersistAiPreference('board.model_override', pageUiState.pageAiBoardModelOverride); - var current = pageAiCurrentSession(); - if (current) { - current.modelOverride = pageUiState.pageAiBoardModelOverride; - current.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - return true; - } - var pageAiBoardWorkflow = closestAction(event.target, '[data-page-ai-board-workflow]'); - if (pageAiBoardWorkflow instanceof HTMLSelectElement) { - pageUiState.pageAiBoardWorkflowId = String(pageAiBoardWorkflow.value || pageAiBoardDefaultWorkflowId()).trim() || pageAiBoardDefaultWorkflowId(); - pageAiPersistAiPreference('board.workflow_id', pageUiState.pageAiBoardWorkflowId); - renderPageAiControls(); - pageAiApplyBoardFirstShell(); - return true; - } - var pageAiSkillSourceSelect = closestAction(event.target, '[data-page-ai-skill-source-select]'); - if (pageAiSkillSourceSelect instanceof HTMLSelectElement) { - pageAiSetSkillSource(pageAiSkillSourceSelect.value); - return true; - } - var pageAiSessionAgentFilter = closestAction(event.target, '[data-page-ai-session-agent-filter]'); - if (pageAiSessionAgentFilter instanceof HTMLSelectElement) { - pageUiState.pageAiSessionAgentFilter = String(pageAiSessionAgentFilter.value || 'all').trim() || 'all'; - renderPageAiControls(); - renderPageAiConversation(); - return true; - } - var pageAiSessionStatusFilter = closestAction(event.target, '[data-page-ai-session-status-filter]'); - if (pageAiSessionStatusFilter instanceof HTMLSelectElement) { - pageUiState.pageAiSessionStatusFilter = String(pageAiSessionStatusFilter.value || 'all').trim() || 'all'; - renderPageAiControls(); - renderPageAiConversation(); - return true; - } - var pageAiHideHermesBuiltin = closestAction(event.target, '[data-page-ai-hide-hermes-builtin]'); - if (pageAiHideHermesBuiltin instanceof HTMLInputElement) { - pageAiSetHideHermesBuiltinSkills(pageAiHideHermesBuiltin.checked); - return true; - } - var pageAiReasonixMemory = closestAction(event.target, '[data-page-ai-reasonix-memory]'); - if (pageAiReasonixMemory instanceof HTMLInputElement) { - pageAiSetReasonixMemoryEnabled(pageAiReasonixMemory.checked); - return true; - } - var pageAiContextSelect = closestAction(event.target, '[data-page-ai-context-scope]'); - if (pageAiContextSelect instanceof HTMLSelectElement) { - pageAiSetContextScope(pageAiContextSelect.value); - renderPageAiControls(); - return true; - } - return false; + return !!(pageUiState && pageUiState.pageAiOpen); } function installPageAiDelegates() { - if (pageAiDelegatesInstalled) return; - pageAiDelegatesInstalled = true; - var helpers = { closestAction: closestAction }; - document.addEventListener('click', function(event) { - handlePageAiClick(event, helpers); - }); - document.addEventListener('submit', function(event) { - var form = event.target && event.target.closest ? event.target.closest('[data-page-ai-opencode-composer]') : null; - if (!form) return; - event.preventDefault(); - var input = form.querySelector('[data-page-ai-opencode-input]'); - var text = input instanceof HTMLTextAreaElement ? input.value.trim() : ''; - if (!text) return; - input.value = ''; - void pageAiSendOpencodeMessage(text).catch(function(error) { - pageUiState.pageAiOpencodeStatusError = error instanceof Error ? error.message : String(error); - pageAiRenderOpencodeHostChrome(); - }); - }); - document.addEventListener('pointerdown', function(event) { - handlePageAiPointerDown(event, helpers); - }); - document.addEventListener('keydown', function(event) { - handlePageAiKeyDown(event, helpers); - }); - document.addEventListener('input', function(event) { - handlePageAiInput(event, helpers); - }); - document.addEventListener('change', function(event) { - handlePageAiChange(event, helpers); - }); - document.addEventListener('visibilitychange', function() { - if (document.visibilityState !== 'visible' || !pageUiState.pageAiOpen) return; - void pageAiCheckAndResumeActiveRun().catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - }); + // no-op:Hermes/OpenCode drawer 事件委托已退役;Pi Lab 自有 runtime 处理交互。 } - var api = { - ensurePageAiStateFacade: (...args) => ensurePageAiStateFacade(...args), - installPageAiDelegates: (...args) => installPageAiDelegates(...args), - handlePageAiClick: (...args) => handlePageAiClick(...args), - handlePageAiKeyDown: (...args) => handlePageAiKeyDown(...args), - handlePageAiInput: (...args) => handlePageAiInput(...args), - handlePageAiChange: (...args) => handlePageAiChange(...args), - openPageAiDrawer: (...args) => openPageAiDrawer(...args), - closePageAiDrawer: (...args) => closePageAiDrawer(...args), - isPageAiDrawerOpen: (...args) => isPageAiDrawerOpen(...args), - ensurePageAiDrawer: (...args) => ensurePageAiDrawer(...args), - sendPageAiMessage: (...args) => sendPageAiMessage(...args), - pageAiOpenHermesSettings: (...args) => pageAiOpenHermesSettings(...args), - pageAiStopRun: (...args) => pageAiStopRun(...args), - pageAiLoadGatewayHealth: (...args) => pageAiLoadGatewayHealth(...args), - renderPageAiControls: (...args) => renderPageAiControls(...args), - renderPageAiConversation: (...args) => renderPageAiConversation(...args), - renderPageAiProviderButtons: (...args) => renderPageAiProviderButtons(...args), - renderPageAiSuggestions: (...args) => renderPageAiSuggestions(...args), - pageAiSaveProfileMemory: (...args) => pageAiSaveProfileMemory(...args), - pageAiToggleSkill: (...args) => pageAiToggleSkill(...args), - pageAiToggleTool: (...args) => pageAiToggleTool(...args), - pageAiResumeBackendSession: (...args) => pageAiResumeBackendSession(...args), - pageAiRenameBackendSession: (...args) => pageAiRenameBackendSession(...args), - pageAiExportBackendSession: (...args) => pageAiExportBackendSession(...args), - pageAiDeleteBackendSession: (...args) => pageAiDeleteBackendSession(...args), - pageAiResolvePermission: (...args) => pageAiResolvePermission(...args), - pageAiOpenLocation: (...args) => pageAiOpenLocation(...args), - pageAiSetActiveSession: (...args) => pageAiSetActiveSession(...args), - pageAiStartNewSession: (...args) => pageAiStartNewSession(...args), - pageAiLoadSessions: (...args) => pageAiLoadSessions(...args), - pageAiLoadBackendSessions: (...args) => pageAiLoadBackendSessions(...args), - pageAiCancelQueuedRun: (...args) => pageAiCancelQueuedRun(...args), - pageAiSearchBackendSessions: (...args) => pageAiSearchBackendSessions(...args), - pageAiSetTargetPopoverOpen: (...args) => pageAiSetTargetPopoverOpen(...args), - pageAiSelectTarget: (...args) => pageAiSelectTarget(...args), - pageAiPersistSessions: (...args) => pageAiPersistSessions(...args), - pageAiLoadProfiles: (...args) => pageAiLoadProfiles(...args), - pageAiLoadProfileMemory: (...args) => pageAiLoadProfileMemory(...args), - pageAiLoadSkills: (...args) => pageAiLoadSkills(...args), - pageAiSwitchProfile: (...args) => pageAiSwitchProfile(...args), - pageAiToggleSkillGroup: (...args) => pageAiToggleSkillGroup(...args), - pageAiSetReasonixMemoryEnabled: (...args) => pageAiSetReasonixMemoryEnabled(...args), - pageAiSetHideHermesBuiltinSkills: (...args) => pageAiSetHideHermesBuiltinSkills(...args), - pageAiSetSkillSource: (...args) => pageAiSetSkillSource(...args), - pageAiSetContextScope: (...args) => pageAiSetContextScope(...args), - pageAiSetAgentId: (...args) => pageAiSetAgentId(...args), - pageAiToggleContextRef: (...args) => pageAiToggleContextRef(...args), - pageAiSetContextPopoverOpen: (...args) => pageAiSetContextPopoverOpen(...args), - pageAiSetAgentPopoverOpen: (...args) => pageAiSetAgentPopoverOpen(...args), - pageAiLoadAllowedRoots: (...args) => pageAiLoadAllowedRoots(...args), - pageAiEnsureOpencodeHostDrawer: (...args) => pageAiEnsureOpencodeHostDrawer(...args), - pageAiLoadOpencodeStatus: (...args) => pageAiLoadOpencodeStatus(...args), - pageAiLoadOpencodeDiff: (...args) => pageAiLoadOpencodeDiff(...args), - pageAiOpenOpencodeChangedFile: (...args) => pageAiOpenOpencodeChangedFile(...args), - pageAiRefreshOpencodeCurrentPage: (...args) => pageAiRefreshOpencodeCurrentPage(...args), - updatePageAiTriggerState: (...args) => updatePageAiTriggerState(...args) + installPageAiDelegates: installPageAiDelegates, + openPageAiDrawer: openPageAiDrawer, + closePageAiDrawer: closePageAiDrawer, + isPageAiDrawerOpen: isPageAiDrawerOpen, + updatePageAiTriggerState: updatePageAiTriggerState }; window.__mnoteSidebarPageAiRuntime = api; return api; diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js deleted file mode 100644 index 9ea8e4b2..00000000 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js +++ /dev/null @@ -1,863 +0,0 @@ -export function createSidebarPageAiSessionRuntime(context) { - const { - currentDocumentId, - currentRootUri, - currentSourceKind, - documentRef, - pageAiApplyRuntimeState, - pageAiCurrentAgentId, - pageAiCurrentProfile, - pageAiErrorMessage, - pageAiNormalizeAgentId, - pageAiNormalizeArray, - pageAiNormalizeChatOnlyProfileId, - pageAiPermissionMessage, - pageAiPreviewValue, - pageAiRunProfile, - pageAiSetActiveProfile, - pageAiTimestamp, - pageUiState, - renderPageAiControls, - renderPageAiConversation, - resolveWorkspaceId, - sessionStorageVersion, - windowRef, - } = context; - const doc = documentRef || document; - const win = windowRef || window; - - function pageAiStorageKey() { - return 'hermes_page_ai_session:' + currentDocumentId(); - } - - function pageAiBackendSessionQuery(extra) { - var params = new URLSearchParams(); - params.set('source', 'acp'); - params.set('workspaceId', resolveWorkspaceId(doc.body)); - params.set('documentId', currentDocumentId()); - params.set('profile', pageAiRunProfile()); - params.set('sourceKind', currentSourceKind()); - if (currentRootUri()) params.set('rootUri', currentRootUri()); - Object.keys(extra || {}).forEach(function(key) { - var value = extra[key]; - if (value !== undefined && value !== null && String(value).trim() !== '') { - params.set(key, String(value)); - } - }); - return params.toString(); - } - - function pageAiNewSession(title) { - var now = Date.now(); - var agentId = pageAiCurrentAgentId(); - var profile = agentId === 'chat_only' ? pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile()) : pageAiCurrentProfile(); - return { - id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8), - title: title || '新会话', - agentId: agentId, - profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? profile : '', - profile: profile, - acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - createdAt: now, - updatedAt: now, - source: 'draft', - usage: null, - status: 'draft', - messages: [] - }; - } - - function pageAiNormalizeSessions(sessions) { - return (Array.isArray(sessions) ? sessions : []) - .slice(0, 20) - .map(function(session) { - var sessionStorage = String(session && (session.sessionStorage || session.session_storage) || '').trim(); - var persistence = String(session && session.persistence || '').trim(); - var agentId = pageAiNormalizeAgentId(session && (session.agentId || session.agent_id)); - var profileId = String(session && (session.profileId || session.profile_id || '') || '').trim(); - var profile = String(session && session.profile || pageAiCurrentProfile()).trim() || 'default'; - if (!profileId && agentId !== 'reasonix') profileId = profile; - if (agentId === 'chat_only') { - profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile); - profile = profileId; - } - return { - schema: String(session && session.schema || '').trim(), - id: String(session && session.id || '').trim() || pageAiNewSession().id, - title: String(session && session.title || '').trim() || '新会话', - agentId: agentId, - profileId: profileId, - profile: profile, - acpRuntime: String(session && session.acpRuntime || 'reasonix').trim(), - createdAt: pageAiTimestamp(session && session.createdAt), - updatedAt: pageAiTimestamp(session && session.updatedAt), - source: String(session && session.source || 'local').trim() || 'local', - persistence: persistence, - sessionStorage: sessionStorage, - permissionLevel: String(session && (session.permissionLevel || session.permission_level) || '').trim(), - shareId: String(session && (session.shareId || session.share_id) || '').trim(), - acpSessionId: String(session && (session.acpSessionId || session.acp_session_id) || '').trim(), - runId: String(session && (session.runId || session.run_id) || '').trim(), - boardRunId: String(session && (session.boardRunId || session.board_run_id || session.runId || session.run_id) || '').trim(), - workflowId: String(session && (session.workflowId || session.workflow_id) || '').trim(), - workerPresetId: String(session && (session.workerPresetId || session.worker_preset_id) || '').trim(), - modelOverride: String(session && (session.modelOverride || session.model_override) || '').trim(), - receiptId: String(session && (session.receiptId || session.receipt_id) || '').trim(), - boardRuns: session && session.boardRuns && typeof session.boardRuns === 'object' ? session.boardRuns : {}, - status: String(session && session.status || '').trim(), - runtimeMode: String(session && (session.runtimeMode || session.runtime_mode) || '').trim(), - replaySeen: Boolean(session && session.replaySeen), - usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null, - preview: String(session && session.preview || '').trim(), - messages: Array.isArray(session && session.messages) ? session.messages.slice(-300).map(function(message) { return Object.assign({}, message); }) : [] - }; - }) - .sort(function(a, b) { - if (a.id === pageUiState.pageAiActiveSessionId && b.id !== pageUiState.pageAiActiveSessionId) return -1; - if (b.id === pageUiState.pageAiActiveSessionId && a.id !== pageUiState.pageAiActiveSessionId) return 1; - return Number(b.updatedAt || 0) - Number(a.updatedAt || 0); - }); - } - - function pageAiNormalizeBackendSessionRow(row) { - if (!row || typeof row !== 'object') return null; - var payload = row.payload && typeof row.payload === 'object' ? row.payload : {}; - var sessionId = String(row.sessionId || row.session_id || '').trim(); - if (!sessionId) return null; - var title = String(row.title || payload.title || payload.message || '').trim(); - if (title.length > 28) title = title.slice(0, 28) + '…'; - var persistence = String(row.persistence || payload.persistence || '').trim(); - var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim(); - var runtime = row.runtime && typeof row.runtime === 'object' ? row.runtime : {}; - var status = String(row.status || runtime.status || '').trim(); - var hasConversationSignal = String(payload.message || payload.input || row.snippet || '').trim() - || pageAiNormalizeArray(row.messages).length > 0; - if (status === 'session.created' && !hasConversationSignal) return null; - var agentId = pageAiNormalizeAgentId(row.agentId || row.agent_id || payload.agentId || payload.agent_id); - var profileId = String(row.profileId || row.profile_id || payload.profileId || payload.profile_id || '').trim(); - var profile = String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default'; - if (!profileId && agentId !== 'reasonix') profileId = profile; - if (agentId === 'chat_only') { - profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile); - profile = profileId; - } - if (!sessionStorage && persistence === 'convex_acp_runtime_store') sessionStorage = 'cloud'; - if (!sessionStorage && persistence === 'local_ai_session_jsonl') sessionStorage = String(row.shareId || payload.shareId || '').trim() ? 'local_shared' : 'local_private'; - return { - id: sessionId, - title: title || '当前页问答', - agentId: agentId, - profileId: profileId, - profile: profile, - acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(), - createdAt: pageAiTimestamp(row.createdAt || row.created_at), - updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at), - source: sessionStorage === 'local_private' || sessionStorage === 'local_shared' ? 'local' : 'acp', - persistence: persistence, - sessionStorage: sessionStorage, - permissionLevel: String(row.permissionLevel || row.permission_level || payload.permissionLevel || '').trim(), - shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(), - acpSessionId: String(row.acpSessionId || row.acp_session_id || payload.acpSessionId || payload.acp_session_id || '').trim(), - runId: String(row.runId || row.run_id || '').trim(), - status: status, - runtimeMode: String(row.runtimeMode || row.runtime_mode || runtime.mode || payload.reasonixSessionMode || '').trim(), - replaySeen: Boolean(row.replaySeen || row.replay_seen || runtime.replaySeen || payload.replay === true), - usage: row.usage && typeof row.usage === 'object' ? row.usage : null, - preview: String(payload.message || row.snippet || '').trim(), - messages: [] - }; - } - - function pageAiMergeSessions(localSessions, backendSessions) { - var byId = {}; - pageAiNormalizeSessions(localSessions).forEach(function(session) { - byId[session.id] = session; - }); - pageAiNormalizeSessions(backendSessions).forEach(function(session) { - var existing = byId[session.id]; - byId[session.id] = Object.assign({}, existing || {}, session, { - messages: session.messages.length ? session.messages : (existing && existing.messages || []) - }); - }); - return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; })); - } - - function pageAiDedupeSessions(sessions) { - var byId = {}; - var ordered = []; - pageAiNormalizeSessions(sessions).forEach(function(session) { - var id = String(session && session.id || '').trim(); - if (!id || byId[id]) return; - byId[id] = true; - ordered.push(session); - }); - return ordered; - } - - function pageAiSessionStorageLabel(session) { - var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim(); - var persistence = String(session && session.persistence || '').trim(); - if (storage === 'local_shared') return '共享会话'; - if (storage === 'local_private') return '本地私有'; - if (storage === 'sqlite_control_plane' || persistence === 'sqlite_acp_runtime_store') return '账号会话'; - if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话'; - if (persistence === 'local_ai_session_jsonl') return '本地私有'; - return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有'; - } - - function pageAiLoadSessions() { - if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return; - try { - var raw = win.localStorage.getItem(pageAiStorageKey()); - var parsed = raw ? JSON.parse(raw) : null; - var activeProfile = String(parsed && parsed.activeProfileName || '').trim(); - var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim(); - var storageVersion = Number(parsed && parsed.version || 0); - if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime; - if (activeProfile) pageAiSetActiveProfile(activeProfile); - var storedSessions = pageAiNormalizeSessions(parsed && parsed.sessions); - if (storageVersion >= sessionStorageVersion && storedSessions.length) { - pageUiState.pageAiSessions = storedSessions; - var activeSessionId = String(parsed && parsed.activeSessionId || '').trim(); - pageUiState.pageAiActiveSessionId = storedSessions.some(function(session) { return session.id === activeSessionId; }) ? activeSessionId : storedSessions[0].id; - var active = pageAiCurrentSession(); - pageUiState.pageAiMessages = active && Array.isArray(active.messages) ? active.messages.slice() : []; - return; - } - } catch (_) {} - var fresh = pageAiNewSession(); - pageUiState.pageAiSessions = [fresh]; - pageUiState.pageAiActiveSessionId = fresh.id; - pageUiState.pageAiMessages = []; - } - - async function pageAiLoadBackendSessions() { - var response = await fetch('/api/page-ai/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status)); - } - var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean); - var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(session) { - var id = String(session && session.id || '').trim(); - return id && !id.startsWith('mnote_') - && (id === pageUiState.pageAiActiveSessionId || (Array.isArray(session.messages) && session.messages.length > 0)); - }); - if (!backendSessions.length) { - if (!draftSessions.length && !pageUiState.pageAiActiveSessionId) { - var fresh = pageAiNewSession(); - pageUiState.pageAiSessions = [fresh]; - pageUiState.pageAiActiveSessionId = fresh.id; - pageUiState.pageAiMessages = []; - } - pageUiState.pageAiSessionError = ''; - renderPageAiConversation(); - renderPageAiControls(); - return []; - } - pageUiState.pageAiSessions = pageAiDedupeSessions(backendSessions.concat(draftSessions)); - if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) { - pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id; - } - var active = pageAiCurrentSession(); - if (active) { - if (active.profile) pageAiSetActiveProfile(active.profile); - if (!pageUiState.pageAiAcpRuntime && active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime; - pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages; - } - pageUiState.pageAiSessionError = ''; - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - return backendSessions; - } - - function pageAiMessageFromRuntimeEvent(event) { - if (!event || typeof event !== 'object') return null; - var eventType = String(event.eventType || event.event_type || event.event || '').trim(); - var payload = event.payload && typeof event.payload === 'object' ? event.payload : event; - if (payload && (payload.source === 'adapter_replay' || payload.replay === true)) return null; - if (eventType === 'message.delta') { - var delta = String(payload.delta || payload.text || payload.output_text || ''); - return delta ? { role: 'assistant', content: delta } : null; - } - if (eventType === 'thought.delta') { - var thought = String(payload.delta || payload.text || '').trim(); - return thought ? { role: 'assistant', kind: 'thought', content: thought } : null; - } - if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') { - var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim(); - var rawLocations = payload.locations; - return { - role: 'tool', - content: toolName, - toolName: toolName, - toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''), - toolKind: String(payload.kind || ''), - status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'), - argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input), - resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error), - locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [], - traceId: String(payload.traceId || payload.trace_id || ''), - auditId: String(payload.auditId || payload.audit_id || '') - }; - } - if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') { - return pageAiPermissionMessage(payload, eventType); - } - if (eventType === 'run.completed') { - var output = String(payload.output || payload.text || '').trim(); - return output ? { role: 'assistant', content: output } : null; - } - return null; - } - - function pageAiApplyBackendSessionDetail(payload) { - var sessionPayload = payload && payload.session ? payload.session : {}; - var runs = pageAiNormalizeArray(sessionPayload.runs); - var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null; - var events = pageAiNormalizeArray(payload && payload.events); - var storedMessages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) { - return { - role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'), - content: String(message.content || '') - }; - }).filter(function(message) { return message.content; }); - var eventsByRunId = {}; - events.forEach(function(event) { - var runId = String(event && (event.runId || event.run_id) || '').trim(); - if (!runId) return; - if (!eventsByRunId[runId]) eventsByRunId[runId] = []; - eventsByRunId[runId].push(event); - }); - var messages = []; - if (runs.length) { - runs.slice().reverse().forEach(function(run) { - var runPayload = run && run.payload && typeof run.payload === 'object' ? run.payload : {}; - var userMessage = String(runPayload.message || runPayload.input || '').trim(); - if (userMessage) messages.push({ role: 'user', content: userMessage }); - var runId = String(run && (run.runId || run.run_id) || '').trim(); - var assistantDelta = ''; - var completedOutput = ''; - function flushAssistantDelta() { - var content = assistantDelta.trim(); - if (content) messages.push({ role: 'assistant', content: content }); - assistantDelta = ''; - } - pageAiNormalizeArray(eventsByRunId[runId]).forEach(function(event) { - var message = pageAiMessageFromRuntimeEvent(event); - if (!message) return; - if (message.role === 'assistant' && !message.kind) { - var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim(); - if (eventType === 'run.completed') { - completedOutput = String(message.content || '').trim(); - return; - } - assistantDelta += String(message.content || ''); - return; - } - flushAssistantDelta(); - messages.push(message); - }); - flushAssistantDelta(); - if (completedOutput && !messages.some(function(message) { - return message.role === 'assistant' && String(message.content || '').trim() === completedOutput; - })) { - messages.push({ role: 'assistant', content: completedOutput }); - } - }); - } - if (!messages.length) messages = storedMessages; - var acpSessionId = ''; - events.forEach(function(event) { - var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim(); - var payload = event && event.payload && typeof event.payload === 'object' ? event.payload : event; - if (eventType === 'session.info.updated') { - var nextAcpSessionId = String(payload && (payload.acpSessionId || payload.acp_session_id) || '').trim(); - if (nextAcpSessionId) acpSessionId = nextAcpSessionId; - } - }); - var current = pageAiCurrentSession(); - if (latest && current) { - Object.assign(current, latest); - } - if (current) { - current.messages = messages.slice(-300); - current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now()); - if (acpSessionId) current.acpSessionId = acpSessionId; - if (latest && latest.usage) current.usage = latest.usage; - } - pageAiApplyRuntimeState(payload && payload.runtime); - pageUiState.pageAiMessages = messages.slice(-300); - pageAiPersistSessions(); - } - - async function pageAiLoadBackendSessionDetail(sessionId) { - sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim(); - if (!sessionId) return null; - var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'session_detail_failed_' + response.status)); - } - pageAiApplyBackendSessionDetail(payload); - renderPageAiConversation(); - renderPageAiControls(); - return payload; - } - - async function pageAiSearchBackendSessions(query) { - var q = String(query || '').trim(); - pageUiState.pageAiSessionSearchQuery = q; - if (!q) { - pageUiState.pageAiSessionSearchResults = []; - renderPageAiConversation(); - return []; - } - var response = await fetch('/api/page-ai/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'session_search_failed_' + response.status)); - } - pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) { - var normalized = pageAiNormalizeBackendSessionRow(row) || {}; - normalized.snippet = String(row && row.snippet || normalized.preview || '').trim(); - return normalized; - }).filter(function(row) { return row.id; }); - renderPageAiConversation(); - return pageUiState.pageAiSessionSearchResults; - } - - async function pageAiExportBackendSession(sessionId) { - sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim(); - if (!sessionId) return null; - var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/export?' + pageAiBackendSessionQuery({ limit: 200 }), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'session_export_failed_' + response.status)); - } - var exported = payload.export && typeof payload.export === 'object' ? payload.export : {}; - var session = pageAiFindSessionById(sessionId) || pageAiCurrentSession(); - if (session) { - session.exportedAt = Date.now(); - session.exportMarkdown = String(exported.markdown || ''); - session.updatedAt = Date.now(); - } - doc.documentElement.setAttribute('data-mnote-page-ai-session-exported', sessionId); - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - return payload; - } - - function pageAiPersistSessions() { - pageAiSyncCurrentSessionMessages(); - doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes'); - doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey()); - try { - win.localStorage.setItem(pageAiStorageKey(), JSON.stringify({ - version: sessionStorageVersion, - activeSessionId: pageUiState.pageAiActiveSessionId, - activeProfileName: pageAiCurrentProfile(), - activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - sessions: pageAiNormalizeArray(pageUiState.pageAiSessions).slice(0, 20) - })); - } catch (_) {} - } - - async function pageAiEnsureHermesSession(forceCreate) { - pageAiLoadSessions(); - var current = pageAiCurrentSession(); - var runProfile = pageAiRunProfile(); - if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === runProfile) return current; - var response = await fetch('/api/page-ai/sessions', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: resolveWorkspaceId(doc.body), - documentId: currentDocumentId(), - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - traceId: 'page-ai-' + Date.now().toString(36), - profile: runProfile, - agentId: pageAiCurrentAgentId(), - profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '', - title: current && current.title ? current.title : '当前页问答' - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'hermes_session_failed_' + response.status)); - } - var session = { - id: String(payload.sessionId || '').trim(), - title: String(payload.title || '当前页问答'), - agentId: pageAiCurrentAgentId(), - profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '', - profile: String(payload.profile || runProfile).trim() || 'default', - acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - persistence: String(payload.persistence || '').trim(), - sessionStorage: String(payload.sessionStorage || '').trim(), - permissionLevel: String(payload.permissionLevel || '').trim(), - shareId: String(payload.shareId || '').trim(), - acpSessionId: String(payload.acpSessionId || payload.acp_session_id || '').trim(), - createdAt: Date.now(), - updatedAt: Date.now(), - messages: pageUiState.pageAiMessages.slice() - }; - var previousSessionId = String(current && current.id || '').trim(); - var retainedSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(item) { - var itemId = String(item && item.id || '').trim(); - return itemId && itemId !== session.id && itemId !== previousSessionId; - }); - pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(retainedSessions)); - pageUiState.pageAiActiveSessionId = session.id; - pageAiPersistSessions(); - renderPageAiConversation(); - return session; - } - - async function pageAiRestoreHermesSession() { - var current = pageAiCurrentSession(); - if (!current || !String(current.id || '').startsWith('mnote_')) return; - var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), { - method: 'POST', - headers: { 'accept': 'application/json' } - }); - if (!response.ok) return; - var payload = await response.json().catch(function(){ return null; }); - if (payload && payload.persistence === 'convex_acp_runtime_store') { - pageAiApplyBackendSessionDetail(payload); - renderPageAiConversation(); - renderPageAiControls(); - return; - } - var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload); - var messages = session && Array.isArray(session.messages) ? session.messages : []; - pageAiApplyRuntimeState(payload && payload.runtime); - if (session && (session.profile || session.profileName)) { - current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile()); - } - if (!messages.length) { - renderPageAiControls(); - return; - } - pageUiState.pageAiMessages = messages.slice(-300).map(function(message) { - return { - role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'), - content: String(message.content || '') - }; - }); - current.messages = pageUiState.pageAiMessages.slice(); - current.updatedAt = Date.now(); - renderPageAiConversation(); - renderPageAiControls(); - } - - function pageAiCurrentSession() { - return pageUiState.pageAiSessions.find(function(session) { - return session.id === pageUiState.pageAiActiveSessionId; - }) || null; - } - - function pageAiSelectedSessionIds() { - if (!pageUiState.pageAiSelectedSessionIds || typeof pageUiState.pageAiSelectedSessionIds !== 'object') { - pageUiState.pageAiSelectedSessionIds = {}; - } - return pageUiState.pageAiSelectedSessionIds; - } - - function pageAiSessionSelected(sessionId) { - sessionId = String(sessionId || '').trim(); - return Boolean(sessionId && pageAiSelectedSessionIds()[sessionId]); - } - - function pageAiSelectedSessionList() { - var selected = pageAiSelectedSessionIds(); - return Object.keys(selected).filter(function(sessionId) { - return selected[sessionId] === true; - }); - } - - function pageAiSelectedSessionCount() { - return pageAiSelectedSessionList().length; - } - - function pageAiToggleSessionSelection(sessionId, selected) { - sessionId = String(sessionId || '').trim(); - if (!sessionId) return; - var selectedMap = Object.assign({}, pageAiSelectedSessionIds()); - if (selected === false) { - delete selectedMap[sessionId]; - } else { - selectedMap[sessionId] = true; - } - pageUiState.pageAiSelectedSessionIds = selectedMap; - renderPageAiConversation(); - renderPageAiControls(); - } - - function pageAiClearSessionSelection() { - pageUiState.pageAiSelectedSessionIds = {}; - renderPageAiConversation(); - renderPageAiControls(); - } - - function pageAiSyncCurrentSessionMessages() { - var session = pageAiCurrentSession(); - if (!session) return; - var runProfile = pageAiRunProfile(); - session.messages = Array.isArray(pageUiState.pageAiMessages) ? pageUiState.pageAiMessages.slice(-300) : []; - session.agentId = pageAiCurrentAgentId(); - session.profileId = pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : ''; - session.profile = runProfile; - session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix'; - if (String(session.source || '') === 'board') { - session.schema = session.schema || 'mnote.page_ai.session.v2'; - session.workerPresetId = pageUiState.pageAiBoardWorkerId || session.workerPresetId || ''; - session.workflowId = pageUiState.pageAiBoardWorkflowId || session.workflowId || ''; - session.modelOverride = pageUiState.pageAiBoardModelOverride || session.modelOverride || ''; - } - session.updatedAt = Date.now(); - } - - function pageAiSetActiveSession(sessionId) { - var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; }); - if (!session) return; - pageUiState.pageAiActiveSessionId = session.id; - if (session.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(session.agentId); - if (session.source === 'board') { - if (session.workerPresetId) pageUiState.pageAiBoardWorkerId = session.workerPresetId; - if (session.workflowId) pageUiState.pageAiBoardWorkflowId = session.workflowId; - if (session.modelOverride) pageUiState.pageAiBoardModelOverride = session.modelOverride; - if (session.boardRuns && typeof session.boardRuns === 'object') { - pageUiState.pageAiBoardRunDetails = Object.assign({}, pageUiState.pageAiBoardRunDetails || {}, session.boardRuns); - } - } - if (session.acpRuntime) pageUiState.pageAiAcpRuntime = session.acpRuntime; - if (session.profileId || session.profile) pageAiSetActiveProfile(session.profileId || session.profile); - pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : []; - pageUiState.pageAiPage = 'chat'; - pageAiPersistSessions(); - renderPageAiControls(); - renderPageAiConversation(); - if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) { - void pageAiLoadBackendSessionDetail(session.id).catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - } - } - - function pageAiStartNewSession() { - var session = pageAiNewSession(); - pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions)); - pageUiState.pageAiActiveSessionId = session.id; - pageUiState.pageAiMessages = []; - pageUiState.pageAiPage = 'chat'; - pageAiPersistSessions(); - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiRenameBackendSession(sessionId) { - var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; }); - if (!session) return; - var title = win.prompt('重命名 AI 会话', session.title || '当前页问答'); - if (title === null) return; - title = String(title || '').trim(); - if (!title) return; - var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), { - method: 'POST', - headers: { 'content-type': 'application/json', 'accept': 'application/json' }, - body: JSON.stringify({ title: title }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status)); - } - var nextTitle = String((payload.result && payload.result.title) || title); - [pageUiState.pageAiSessions, pageUiState.pageAiSessionSearchResults].forEach(function(list) { - pageAiNormalizeArray(list).forEach(function(item) { - if (String(item && item.id || '').trim() === sessionId) { - item.title = nextTitle; - item.updatedAt = Date.now(); - } - }); - }); - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - } - - function pageAiSessionRequiresBackendDelete(session) { - var id = String(session && session.id || '').trim(); - var source = String(session && session.source || '').trim(); - return Boolean(id && ( - id.startsWith('mnote_') - || source === 'acp' - || String(session && session.persistence || '').trim() - || String(session && (session.sessionStorage || session.session_storage) || '').trim() - )); - } - - function pageAiFindSessionById(sessionId) { - sessionId = String(sessionId || '').trim(); - if (!sessionId) return null; - return pageAiNormalizeArray(pageUiState.pageAiSessions).find(function(item) { - return String(item && item.id || '').trim() === sessionId; - }) || pageAiNormalizeArray(pageUiState.pageAiSessionSearchResults).find(function(item) { - return String(item && item.id || '').trim() === sessionId; - }) || null; - } - - function pageAiApplyDeletedSessionIds(sessionIds) { - var deleted = {}; - pageAiNormalizeArray(sessionIds).forEach(function(sessionId) { - sessionId = String(sessionId || '').trim(); - if (sessionId) deleted[sessionId] = true; - }); - pageUiState.pageAiSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(item) { - return !deleted[String(item && item.id || '').trim()]; - }); - pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(pageUiState.pageAiSessionSearchResults).filter(function(item) { - return !deleted[String(item && item.id || '').trim()]; - }); - var selected = Object.assign({}, pageAiSelectedSessionIds()); - Object.keys(deleted).forEach(function(sessionId) { delete selected[sessionId]; }); - pageUiState.pageAiSelectedSessionIds = selected; - if (deleted[pageUiState.pageAiActiveSessionId]) { - var next = pageUiState.pageAiSessions[0] || pageAiNewSession(); - if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next]; - pageUiState.pageAiActiveSessionId = next.id; - pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : []; - } - } - - async function pageAiDeleteBackendSessions(sessionIds) { - var ids = pageAiNormalizeArray(sessionIds).map(function(sessionId) { - return String(sessionId || '').trim(); - }).filter(Boolean); - if (!ids.length) return; - var sessions = ids.map(pageAiFindSessionById).filter(Boolean); - if (!sessions.length) return; - var confirmText = sessions.length === 1 - ? '确定删除 AI 会话“' + (sessions[0].title || sessions[0].id) + '”吗?仅删除 MNote 历史,不删除外部 Hermes/provider 会话。' - : '确定删除选中的 ' + String(sessions.length) + ' 个 AI 会话吗?仅删除 MNote 历史,不删除外部 Hermes/provider 会话。'; - if (!win.confirm(confirmText)) return; - for (var index = 0; index < sessions.length; index += 1) { - var session = sessions[index]; - if (!pageAiSessionRequiresBackendDelete(session)) continue; - var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(session.id) + '?' + pageAiBackendSessionQuery({}), { - method: 'DELETE', - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'session_delete_failed_' + response.status)); - } - } - pageAiApplyDeletedSessionIds(sessions.map(function(session) { return session.id; })); - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - } - - async function pageAiDeleteBackendSession(sessionId) { - return pageAiDeleteBackendSessions([sessionId]); - } - - async function pageAiDeleteSelectedBackendSessions() { - return pageAiDeleteBackendSessions(pageAiSelectedSessionList()); - } - - async function pageAiResumeBackendSession(sessionId) { - sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim(); - if (!sessionId) return; - pageAiSetActiveSession(sessionId); - var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), { - method: 'POST', - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'session_resume_failed_' + response.status)); - } - pageAiApplyBackendSessionDetail(payload); - pageUiState.pageAiPage = 'chat'; - renderPageAiConversation(); - renderPageAiControls(); - } - - async function pageAiCheckActiveRun(sessionId) { - sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim(); - if (!sessionId) return null; - var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/active-run?' + pageAiBackendSessionQuery({}), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'active_run_failed_' + response.status)); - } - if (!payload.active || !payload.run) return null; - var run = payload.run; - var hostRunId = String(run.hostRunId || run.runId || '').trim(); - var status = String(run.status || '').trim(); - var current = pageAiCurrentSession(); - if (current && current.id === sessionId) { - current.runId = hostRunId; - current.status = status || current.status || 'running'; - current.updatedAt = Date.now(); - } - pageAiApplyRuntimeState(Object.assign({}, run.runtime || {}, { - status: status || 'running', - runId: hostRunId - })); - doc.documentElement.setAttribute('data-mnote-page-ai-active-run-checked', 'true'); - if (hostRunId) doc.documentElement.setAttribute('data-mnote-page-ai-active-host-run-id', hostRunId); - pageAiPersistSessions(); - renderPageAiControls(); - return run; - } - - return { - pageAiStorageKey, - pageAiBackendSessionQuery, - pageAiNewSession, - pageAiNormalizeSessions, - pageAiNormalizeBackendSessionRow, - pageAiMergeSessions, - pageAiSessionStorageLabel, - pageAiLoadSessions, - pageAiLoadBackendSessions, - pageAiMessageFromRuntimeEvent, - pageAiApplyBackendSessionDetail, - pageAiLoadBackendSessionDetail, - pageAiSearchBackendSessions, - pageAiPersistSessions, - pageAiEnsureHermesSession, - pageAiRestoreHermesSession, - pageAiCurrentSession, - pageAiSessionSelected, - pageAiToggleSessionSelection, - pageAiClearSessionSelection, - pageAiSelectedSessionCount, - pageAiSyncCurrentSessionMessages, - pageAiSetActiveSession, - pageAiStartNewSession, - pageAiRenameBackendSession, - pageAiExportBackendSession, - pageAiDeleteBackendSession, - pageAiDeleteSelectedBackendSessions, - pageAiResumeBackendSession, - pageAiCheckActiveRun - }; -} diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-skill-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-skill-runtime.js deleted file mode 100644 index 08b4798d..00000000 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-skill-runtime.js +++ /dev/null @@ -1,310 +0,0 @@ -export function createSidebarPageAiSkillRuntime(context) { - const { - pageAiCurrentAgentId, - pageAiCurrentProfile, - pageAiLoadSkills, - pageAiNormalizeArray, - pageAiPersistAiPreference, - pageAiPersistRawAiPreference, - pageAiProfileValue, - pageUiState, - renderPageAiControls, - } = context; - - function pageAiSkillSourceOptions() { - var options = [ - { value: 'mnote', group: 'mnote', label: 'MNote 公共能力', profile: '' }, - { value: 'reasonix', group: 'reasonix', label: 'Reasonix skill(查看)', profile: '', readonly: true } - ]; - pageAiNormalizeArray(pageUiState.pageAiProfiles).forEach(function(profile) { - var profileId = pageAiProfileValue(profile); - if (!profileId || pageAiProfileIsChatOnlySkillSource(profile)) return; - var label = profile.kind === 'shared' ? 'Hermes 共享 skill(查看)' : 'Hermes skill(查看)'; - var alias = String(profile.alias || profile.displayName || '').trim(); - options.push({ - value: 'hermes:' + profileId, - group: 'hermes', - profile: profileId, - label: alias && alias !== label ? label + ' · ' + alias : label, - readonly: true - }); - }); - return options; - } - - function pageAiProfileIsChatOnlySkillSource(profile) { - var profileId = String(pageAiProfileValue(profile) || '').trim().toLowerCase(); - var baseProfile = String(profile && profile.baseProfile || '').trim().toLowerCase(); - var label = String(profile && (profile.displayName || profile.alias || profile.name) || '').trim().toLowerCase(); - var providerKind = String(profile && profile.providerKind || '').trim().toLowerCase(); - return profileId.indexOf('chat') >= 0 - || baseProfile.indexOf('chat') >= 0 - || label.indexOf('chat') >= 0 - || providerKind.indexOf('chat') >= 0 - || profileId === 'shared_lite' - || baseProfile === 'lite'; - } - - function pageAiDefaultSkillSource() { - return 'mnote'; - } - - function pageAiNormalizeSkillSource(source) { - var value = String(source || '').trim(); - var options = pageAiSkillSourceOptions(); - if (options.some(function(option) { return option.value === value; })) return value; - if (value === 'hermes') return 'hermes:' + pageAiCurrentProfile(); - if (value === 'mnote_builtin') return 'mnote'; - var fallback = pageAiDefaultSkillSource(); - if (options.some(function(option) { return option.value === fallback; })) return fallback; - return options.length ? options[0].value : 'mnote'; - } - - function pageAiCurrentSkillSource() { - var normalized = pageAiNormalizeSkillSource(pageUiState.pageAiActiveSkillSource); - pageUiState.pageAiActiveSkillSource = normalized; - return normalized; - } - - function pageAiSetSkillSource(source) { - var normalized = pageAiNormalizeSkillSource(source); - pageUiState.pageAiActiveSkillSource = normalized; - pageAiPersistAiPreference('skills.active_source', normalized); - pageUiState.pageAiSkills = { categories: [], archived: [] }; - pageUiState.pageAiSkillError = ''; - void pageAiLoadSkills(); - renderPageAiControls(); - } - - function pageAiSkillSourceParts(source) { - var normalized = pageAiNormalizeSkillSource(source); - if (normalized.indexOf('hermes:') === 0) { - return { group: 'hermes', profile: normalized.slice('hermes:'.length), source: normalized }; - } - if (normalized === 'reasonix') return { group: 'reasonix', profile: '', source: normalized }; - return { group: 'mnote', profile: '', source: 'mnote' }; - } - - function pageAiCurrentSkillSourceLabel() { - var source = pageAiCurrentSkillSource(); - var option = pageAiSkillSourceOptions().find(function(item) { return item.value === source; }); - return option ? option.label : source; - } - - function pageAiSkillOriginLabel(skill) { - var origin = String(skill && skill.origin || '').trim(); - if (origin === 'generated' || String(skill && skill.createdBy || '') === 'agent') return '生成'; - if (origin === 'installed') return '安装'; - if (origin === 'builtin') return '内置'; - if (origin === 'copied') return '本地'; - var source = String(skill && skill.source || '').trim(); - if (source === 'hub') return '安装'; - if (source === 'builtin') return '内置'; - if (source === 'reasonix') { - if (origin === 'project') return 'Reasonix 项目'; - if (origin === 'global') return 'Reasonix 全局'; - return 'Reasonix'; - } - return '本地'; - } - - function pageAiSkillPreferenceKey(group, profile) { - var groupName = String(group || '').trim(); - if (groupName === 'mnote') return 'ai.agent.mnote_builtin.skills.enabled'; - if (groupName === 'reasonix') return 'ai.agent.reasonix.skills.enabled'; - if (groupName === 'hermes') { - var nextProfile = String(profile || pageAiCurrentProfile() || 'default').trim() || 'default'; - return 'ai.agent.hermes.profile.' + nextProfile + '.skills.enabled'; - } - return ''; - } - - function pageAiSkillPreferenceTable(group, profile) { - var key = pageAiSkillPreferenceKey(group, profile); - var preferences = pageUiState.pageAiSkillPreferences || {}; - var value = preferences[key]; - if (value && typeof value === 'object' && !Array.isArray(value)) return value; - return {}; - } - - function pageAiHermesHideBuiltinPreferenceKey(profile) { - return 'ai.agent.hermes.skills.hide_builtin'; - } - - function pageAiHideHermesBuiltinSkills(profile) { - var preferences = pageUiState.pageAiSkillPreferences || {}; - return preferences[pageAiHermesHideBuiltinPreferenceKey(profile)] === true; - } - - function pageAiReasonixMemoryEnabled() { - var preferences = pageUiState.pageAiSkillPreferences || {}; - return preferences['ai.agent.reasonix.memory_enabled'] === true; - } - - function pageAiSetReasonixMemoryEnabled(enabled) { - var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {}); - preferences['ai.agent.reasonix.memory_enabled'] = Boolean(enabled); - pageUiState.pageAiSkillPreferences = preferences; - pageAiPersistRawAiPreference('ai.agent.reasonix.memory_enabled', Boolean(enabled)); - renderPageAiControls(); - } - - function pageAiSetHideHermesBuiltinSkills(enabled, profile) { - var key = pageAiHermesHideBuiltinPreferenceKey(profile); - var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {}); - preferences[key] = Boolean(enabled); - pageUiState.pageAiSkillPreferences = preferences; - pageAiPersistRawAiPreference(key, Boolean(enabled)); - renderPageAiControls(); - } - - function pageAiSkillIsBuiltin(skill) { - var origin = String(skill && skill.origin || '').trim(); - var source = String(skill && skill.source || '').trim(); - return origin === 'builtin' || source === 'builtin'; - } - - function pageAiToggleableSkillEntries(group, profile) { - var catalogKey = group === 'hermes' && profile ? 'hermes:' + profile : group; - var catalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[catalogKey] - ? pageUiState.pageAiSkillCatalogs[catalogKey] - : pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[group] - ? pageUiState.pageAiSkillCatalogs[group] - : { categories: [], archived: [] }; - var overrides = pageAiSkillPreferenceTable(group, profile); - var result = []; - pageAiNormalizeArray(catalog.categories).forEach(function(category) { - pageAiNormalizeArray(category.skills).forEach(function(skill) { - var id = String(skill.id || skill.name || '').trim(); - if (!id) return; - result.push({ - group: group, - profile: profile || '', - category: category.name, - categoryTitle: skill.categoryTitle || category.title || category.name || '', - id: id, - name: skill.name || id, - title: skill.title || skill.name || id, - description: skill.description || '', - enabled: group === 'mnote' ? (skill.enabled !== false && overrides[id] !== false) : skill.enabled !== false, - toggleable: group === 'mnote' && skill.toggleable !== false, - source: skill.source || group, - origin: skill.origin || '', - readOnly: group !== 'mnote' || skill.readOnly === true || skill.readonly === true || skill.configurable === false, - builtin: pageAiSkillIsBuiltin(skill), - configurable: skill.configurable !== false, - configScope: skill.configScope || '', - skillKind: skill.skillKind || '', - profileId: skill.profileId || '', - toolNames: skill.toolNames || [], - tools: skill.tools || [], - toolCount: Number(skill.toolCount || 0), - disabledToolCount: Number(skill.disabledToolCount || 0), - status: skill.status || '', - capabilityId: skill.capabilityId || '', - capabilityKind: skill.capabilityKind || '', - uiKind: skill.uiKind || '', - requiresContextRefs: skill.requiresContextRefs || [] - }); - }); - }); - pageAiNormalizeArray(catalog.archived).forEach(function(skill) { - var id = String(skill.id || skill.name || '').trim(); - if (!id) return; - result.push({ - group: group, - profile: profile || '', - category: 'archived', - categoryTitle: skill.categoryTitle || 'archived', - id: id, - name: skill.name || id, - title: skill.title || skill.name || id, - description: skill.description || '', - enabled: group === 'mnote' ? (skill.enabled !== false && overrides[id] !== false) : skill.enabled !== false, - toggleable: group === 'mnote' && skill.toggleable !== false, - source: skill.source || group, - origin: skill.origin || '', - readOnly: group !== 'mnote' || skill.readOnly === true || skill.readonly === true || skill.configurable === false, - builtin: pageAiSkillIsBuiltin(skill), - configurable: skill.configurable !== false, - configScope: skill.configScope || '', - skillKind: skill.skillKind || '', - profileId: skill.profileId || '', - toolNames: skill.toolNames || [], - tools: skill.tools || [], - toolCount: Number(skill.toolCount || 0), - disabledToolCount: Number(skill.disabledToolCount || 0), - status: skill.status || '', - capabilityId: skill.capabilityId || '', - capabilityKind: skill.capabilityKind || '', - uiKind: skill.uiKind || '', - requiresContextRefs: skill.requiresContextRefs || [] - }); - }); - return result; - } - - function pageAiAllSkillEntries() { - return [] - .concat(pageAiToggleableSkillEntries('mnote', '')) - .concat(pageAiToggleableSkillEntries('reasonix', '')) - .concat(pageAiToggleableSkillEntries('hermes', pageAiCurrentProfile())); - } - - function pageAiSkillGroupCollapsed(group) { - var table = pageUiState.pageAiCollapsedSkillGroups || {}; - return table[String(group || '').trim()] === true; - } - - function pageAiToggleSkillGroup(group) { - var normalized = String(group || '').trim(); - if (!normalized) return; - var table = Object.assign({}, pageUiState.pageAiCollapsedSkillGroups || {}); - table[normalized] = table[normalized] !== true; - pageUiState.pageAiCollapsedSkillGroups = table; - pageAiPersistRawAiPreference('ai.common.skills.groups.collapsed', table); - renderPageAiControls(); - } - - function pageAiSetSkillPreference(group, skillId, enabled, profile) { - var key = pageAiSkillPreferenceKey(group, profile); - if (!key || !skillId) return; - var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {}); - var current = preferences[key] && typeof preferences[key] === 'object' && !Array.isArray(preferences[key]) - ? Object.assign({}, preferences[key]) - : {}; - current[skillId] = Boolean(enabled); - preferences[key] = current; - pageUiState.pageAiSkillPreferences = preferences; - pageAiPersistRawAiPreference(key, current); - } - - function pageAiSkillEnabled(skill) { - return skill.enabled !== false; - } - - return { - pageAiSkillSourceOptions, - pageAiDefaultSkillSource, - pageAiNormalizeSkillSource, - pageAiCurrentSkillSource, - pageAiSetSkillSource, - pageAiSkillSourceParts, - pageAiCurrentSkillSourceLabel, - pageAiSkillOriginLabel, - pageAiSkillPreferenceKey, - pageAiSkillPreferenceTable, - pageAiHermesHideBuiltinPreferenceKey, - pageAiHideHermesBuiltinSkills, - pageAiReasonixMemoryEnabled, - pageAiSetReasonixMemoryEnabled, - pageAiSetHideHermesBuiltinSkills, - pageAiSkillIsBuiltin, - pageAiToggleableSkillEntries, - pageAiAllSkillEntries, - pageAiSkillGroupCollapsed, - pageAiToggleSkillGroup, - pageAiSetSkillPreference, - pageAiSkillEnabled - }; -} diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js deleted file mode 100644 index 66f417f8..00000000 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js +++ /dev/null @@ -1,773 +0,0 @@ -export function createSidebarPageAiTargetRuntime(context) { - const { - currentDocumentId, - currentRootUri, - currentSourceKind, - currentPageOptions, - documentRef, - escapeHtml, - pageAiEnsureContextRefState, - pageUiState, - resolveWorkspaceId, - searchText, - pageAiNormalizeArray, - } = context; - - function pageAiCloneJson(value) { - if (value == null) return null; - try { - return JSON.parse(JSON.stringify(value)); - } catch (_) { - return null; - } - } - - function localMarkdownRelativePathFromPageAiDocumentId(documentId) { - var value = String(documentId || '').trim(); - if (!value.startsWith('local-md:')) return ''; - return value.slice('local-md:'.length).replace(/~2F/g, '/'); - } - - function localMarkdownDocumentIdFromPageAiRelativePath(relativePath) { - var normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, ''); - if (!normalized) return ''; - return 'local-md:' + normalized.split('/').map(function(segment) { - return encodeURIComponent(segment).replace(/%20/g, '~20'); - }).join('~2F'); - } - - function pageAiWorkspacePathForDocument(documentId, seed) { - var relativePath = String(seed && seed.relativePath || localMarkdownRelativePathFromPageAiDocumentId(documentId) || '').trim().replace(/\\/g, '/').replace(/^\/+/, ''); - var resolvedDocumentId = String(documentId || seed && seed.documentId || '').trim() - || localMarkdownDocumentIdFromPageAiRelativePath(relativePath); - return { - schema: 'mnote.workspace_path.v1', - workspaceId: String(seed && seed.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim(), - sourceKind: String(seed && seed.sourceKind || currentSourceKind() || '').trim(), - rootUri: String(seed && seed.rootUri || currentRootUri() || '').trim(), - relativePath: relativePath, - documentId: resolvedDocumentId, - objectIdentity: seed && seed.objectIdentity && typeof seed.objectIdentity === 'object' - ? seed.objectIdentity - : String(seed && seed.objectIdentity || resolvedDocumentId || '').trim(), - assetId: String(seed && seed.assetId || '').trim(), - resourceKind: String(seed && seed.resourceKind || 'markdown_page').trim() - }; - } - - function pageAiResourceKindForTarget(entry) { - var kind = String(entry && (entry.editorKind || entry.kind) || '').trim().toLowerCase(); - var assetId = String(entry && entry.assetId || '').trim(); - var path = String(entry && entry.path || '').trim().toLowerCase(); - var workspacePath = entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {}; - var workspaceResourceKind = String(workspacePath.resourceKind || '').trim().toLowerCase(); - var officeOpenMode = String(entry && entry.officeOpenMode || '').trim().toLowerCase(); - var onlyofficeSessionId = String(entry && (entry.onlyofficeSessionId || entry.bridgeSessionId) || '').trim(); - if (kind === 'page' || kind === 'markdown_page') return 'markdown_page'; - if (kind === 'mindmap' || path.endsWith('.mindmap.json')) return 'mindmap'; - if (kind === 'office' || kind === 'onlyoffice' || kind === 'only_office' || workspaceResourceKind === 'only_office' || /\.(doc|docx|ppt|pptx|xls|xlsx)$/.test(path)) { - return officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment'; - } - if (kind === 'resource' && assetId) return 'resource'; - return kind || 'markdown_page'; - } - - function pageAiIsOnlyOfficeLiveTarget(editorTarget) { - var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {}; - var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim().toLowerCase(); - var officeOpenMode = String(editorTarget && editorTarget.officeOpenMode || '').trim().toLowerCase(); - return resourceKind === 'only_office' || resourceKind === 'onlyoffice' || officeOpenMode === 'onlyoffice_live'; - } - - function pageAiTargetId(entry) { - if (!entry || typeof entry !== 'object') return ''; - var workspacePath = entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {}; - var entryIdentity = typeof entry.objectIdentity === 'string' ? entry.objectIdentity : ''; - var workspaceIdentity = typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : ''; - return String(entryIdentity || workspaceIdentity || entry.documentId || entry.assetId || entry.path || '').trim(); - } - - function pageAiWorkspacePathForTarget(entry) { - var seed = Object.assign({}, entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {}); - var resourceKind = pageAiResourceKindForTarget(entry); - if (!seed.relativePath && entry && entry.path) seed.relativePath = entry.path; - if (!seed.assetId && entry && entry.assetId) seed.assetId = entry.assetId; - var seedResourceKind = String(seed.resourceKind || '').trim(); - if (!seedResourceKind || seedResourceKind === 'page' || seedResourceKind === 'office') seed.resourceKind = resourceKind; - if (!seed.objectIdentity && entry && entry.objectIdentity) seed.objectIdentity = entry.objectIdentity; - if (!seed.workspaceId && entry && entry.workspaceId) seed.workspaceId = entry.workspaceId; - return pageAiWorkspacePathForDocument(entry && entry.documentId || currentDocumentId(), seed); - } - - function pageAiTargetFromOpenEditor(entry, source) { - if (!entry || typeof entry !== 'object') return null; - var workspacePath = pageAiWorkspacePathForTarget(entry); - var targetId = pageAiTargetId(entry) || workspacePath.objectIdentity || workspacePath.documentId; - if (!targetId) return null; - var objectIdentity = typeof entry.objectIdentity === 'string' - ? entry.objectIdentity - : (typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : targetId); - return { - schema: 'mnote.ai_editor_target.v1', - source: source || 'open_editors_snapshot', - targetId: targetId, - objectIdentity: objectIdentity, - workspacePath: workspacePath, - paneRole: entry.paneRole || 'primary', - documentId: entry.documentId || workspacePath.documentId, - workspaceId: entry.workspaceId || workspacePath.workspaceId || resolveWorkspaceId(documentRef.body), - editorKind: entry.editorKind || entry.kind || workspacePath.resourceKind, - resourceKind: workspacePath.resourceKind, - title: entry.title || '', - active: entry.active === true, - dirtyState: entry.dirtyState || '', - preview: entry.preview === true, - pinned: entry.pinned === true, - lastActiveAt: entry.lastActiveAt || 0, - assetId: entry.assetId || workspacePath.assetId || '', - path: entry.path || workspacePath.relativePath || '', - officeOpenMode: entry.officeOpenMode || '', - onlyofficeSessionId: entry.onlyofficeSessionId || entry.bridgeSessionId || '', - bridgeSessionId: entry.bridgeSessionId || entry.onlyofficeSessionId || '', - bridgeSessionReady: entry.bridgeSessionReady === true - }; - } - - function normalizeOpenEditorEntry(entry) { - if (!entry || typeof entry !== 'object') return null; - return { - objectIdentity: String(entry.objectIdentity || '').trim(), - workspacePath: entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : null, - paneRole: String(entry.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary', - documentId: String(entry.documentId || '').trim(), - workspaceId: String(entry.workspaceId || '').trim(), - title: String(entry.title || '').trim(), - kind: String(entry.kind || entry.editorKind || '').trim(), - editorKind: String(entry.editorKind || entry.kind || '').trim(), - active: entry.active === true, - dirtyState: String(entry.dirtyState || entry.dirtyGuard || '').trim(), - preview: entry.preview === true, - pinned: entry.pinned === true, - lastActiveAt: Number(entry.lastActiveAt || 0) || 0, - assetId: String(entry.assetId || '').trim(), - path: String(entry.path || '').trim(), - officeOpenMode: String(entry.officeOpenMode || '').trim(), - onlyofficeSessionId: String(entry.onlyofficeSessionId || entry.bridgeSessionId || '').trim(), - bridgeSessionId: String(entry.bridgeSessionId || entry.onlyofficeSessionId || '').trim(), - bridgeSessionReady: entry.bridgeSessionReady === true - }; - } - - function currentPageAiOpenEditorsSnapshot() { - var snapshot = null; - try { - if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === 'function') { - snapshot = window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot(); - } - } catch (_) {} - if (!snapshot || typeof snapshot !== 'object') snapshot = window.__mnoteOpenEditorsSnapshot || null; - if (!snapshot || typeof snapshot !== 'object') return null; - var editors = Array.isArray(snapshot.editors) - ? snapshot.editors.map(normalizeOpenEditorEntry).filter(Boolean) - : []; - var resources = Array.isArray(snapshot.resourceEditors) - ? snapshot.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean) - : editors.filter(function(entry) { return entry.kind !== 'page'; }); - var normalizeGroup = function(group, paneRole) { - var groupEditors = group && Array.isArray(group.editors) - ? group.editors.map(normalizeOpenEditorEntry).filter(Boolean) - : editors.filter(function(entry) { return entry.paneRole === paneRole; }); - var groupResources = group && Array.isArray(group.resourceEditors) - ? group.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean) - : resources.filter(function(entry) { return entry.paneRole === paneRole; }); - var groupActive = groupEditors.find(function(entry) { return entry.active; }) || null; - return { - paneRole: paneRole, - activeObjectIdentity: String(group && group.activeObjectIdentity || (groupActive ? groupActive.objectIdentity : '') || '').trim(), - editors: groupEditors, - resourceEditors: groupResources - }; - }; - var groups = { - primary: normalizeGroup(snapshot.groups && snapshot.groups.primary, 'primary'), - secondary: normalizeGroup(snapshot.groups && snapshot.groups.secondary, 'secondary') - }; - var activeObjectIdentity = String(snapshot.activeObjectIdentity || '').trim(); - var allTargets = editors.concat(resources); - var activeEditor = allTargets.find(function(entry) { - return entry.active && (!activeObjectIdentity || entry.objectIdentity === activeObjectIdentity); - }) || groups.primary.editors.find(function(entry) { - return entry.active; - }) || groups.primary.resourceEditors.find(function(entry) { - return entry.active; - }) || groups.secondary.editors.find(function(entry) { - return entry.active; - }) || groups.secondary.resourceEditors.find(function(entry) { - return entry.active; - }) || null; - return { - schema: String(snapshot.schema || 'mnote.open_editors_snapshot.v1'), - generatedAt: Number(snapshot.generatedAt || 0) || Date.now(), - activeObjectIdentity: activeObjectIdentity || (activeEditor ? activeEditor.objectIdentity : ''), - activeEditor: activeEditor, - editors: editors, - resourceEditors: resources, - groups: groups - }; - } - - function pageAiFallbackEditorTarget() { - var fallbackDocumentId = currentDocumentId(); - var fallbackWorkspacePath = pageAiWorkspacePathForDocument(fallbackDocumentId, null); - var fallbackTargetId = fallbackWorkspacePath.objectIdentity || fallbackDocumentId || ''; - return { - schema: 'mnote.ai_editor_target.v1', - source: 'fallback_current_document', - targetId: fallbackTargetId, - objectIdentity: fallbackTargetId, - workspacePath: fallbackWorkspacePath, - paneRole: 'primary', - documentId: fallbackDocumentId, - workspaceId: resolveWorkspaceId(documentRef.body), - editorKind: 'page', - active: true, - dirtyState: '', - preview: false, - pinned: true, - lastActiveAt: Date.now(), - assetId: '', - path: '' - }; - } - - function pageAiEditorTargetCandidates() { - var snapshot = currentPageAiOpenEditorsSnapshot(); - var entries = []; - if (snapshot) { - entries = pageAiNormalizeArray(snapshot.editors).concat(pageAiNormalizeArray(snapshot.resourceEditors)); - } - var seen = {}; - var targets = entries.map(function(entry) { - return pageAiTargetFromOpenEditor(entry, 'open_editors_snapshot'); - }).filter(function(target) { - var id = String(target && target.targetId || '').trim(); - if (!id || seen[id]) return false; - seen[id] = true; - return true; - }); - if (!targets.length) targets.push(pageAiFallbackEditorTarget()); - return targets; - } - - function currentPageAiEditorTarget() { - var targets = pageAiEditorTargetCandidates(); - var selectedId = String(pageUiState.pageAiSelectedTargetId || '').trim(); - var selected = selectedId ? targets.find(function(target) { return target.targetId === selectedId; }) : null; - return selected - || targets.find(function(target) { return target.active === true; }) - || targets[0] - || pageAiFallbackEditorTarget(); - } - - function currentPageAiPageEditorTarget() { - var snapshot = currentPageAiOpenEditorsSnapshot(); - var documentId = currentDocumentId(); - var workspaceId = resolveWorkspaceId(documentRef.body); - var sourceKind = currentSourceKind(); - var rootUri = currentRootUri(); - var relativePath = localMarkdownRelativePathFromPageAiDocumentId(documentId); - var fallbackWorkspacePath = pageAiWorkspacePathForDocument(documentId, { - relativePath: relativePath - }); - var pageEditor = snapshot && Array.isArray(snapshot.editors) - ? snapshot.editors.find(function(entry) { - return entry - && String(entry.editorKind || entry.kind || '') === 'page' - && String(entry.documentId || '').trim() === String(documentId || '').trim(); - }) - : null; - var workspacePath = pageEditor && pageEditor.workspacePath - ? Object.assign({}, pageEditor.workspacePath, { - workspaceId: workspaceId, - sourceKind: sourceKind, - rootUri: rootUri, - relativePath: relativePath, - documentId: documentId, - resourceKind: pageEditor.workspacePath.resourceKind || 'markdown_page', - objectIdentity: pageEditor.workspacePath.objectIdentity || fallbackWorkspacePath.objectIdentity - }) - : fallbackWorkspacePath; - var objectIdentity = String(pageEditor && pageEditor.objectIdentity || workspacePath.objectIdentity || documentId || '').trim(); - return { - schema: 'mnote.ai_editor_target.v1', - source: pageEditor ? 'current_page_from_open_editors_snapshot' : 'current_page_fallback', - targetId: objectIdentity, - objectIdentity: objectIdentity, - workspacePath: Object.assign({}, workspacePath, { objectIdentity: objectIdentity }), - paneRole: String(pageEditor && pageEditor.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary', - documentId: documentId, - workspaceId: workspaceId, - editorKind: 'page', - active: pageEditor ? pageEditor.active === true : true, - dirtyState: String(pageEditor && pageEditor.dirtyState || '').trim(), - preview: pageEditor ? pageEditor.preview === true : false, - pinned: true, - lastActiveAt: Number(pageEditor && pageEditor.lastActiveAt || 0) || Date.now(), - assetId: '', - path: relativePath - }; - } - - function currentPageAiScopedEditorTarget() { - var selected = pageAiEnsureContextRefState(); - return selected.active_editor ? currentPageAiEditorTarget() : currentPageAiPageEditorTarget(); - } - - function pageAiSetRunTargetSnapshot(snapshot) { - pageUiState.pageAiCurrentRunTargetSnapshot = snapshot || null; - var target = snapshot && snapshot.editorTarget ? snapshot.editorTarget : null; - var documentId = String(target && target.documentId || snapshot && snapshot.documentId || '').trim(); - var workspaceId = String(target && target.workspaceId || snapshot && snapshot.workspaceId || '').trim(); - var rootUri = String(target && target.workspacePath && target.workspacePath.rootUri || snapshot && snapshot.rootUri || '').trim(); - if (documentId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-document-id', documentId); - if (workspaceId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-workspace-id', workspaceId); - if (rootUri) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-root-uri', rootUri); - } - - function assertPageAiTargetInCurrentWorkspace(editorTarget) { - var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {}; - var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {}; - var targetSourceKind = String(workspacePath.sourceKind || '').trim(); - var currentKind = String(currentSourceKind() || '').trim(); - if (targetSourceKind && currentKind && targetSourceKind !== currentKind) { - var sourceError = new Error('AI target 与当前页面 sourceKind 不一致,请重新选择当前工作区内的目标。'); - sourceError.code = 'page_ai_target_workspace_mismatch'; - throw sourceError; - } - var targetRootUri = String(workspacePath.rootUri || '').trim(); - var currentRoot = String(currentRootUri() || '').trim(); - if (targetRootUri && currentRoot && targetRootUri !== currentRoot) { - var rootError = new Error('AI target 与当前本地工作区 rootUri 不一致,请重新选择当前工作区内的目标。'); - rootError.code = 'page_ai_target_workspace_mismatch'; - throw rootError; - } - var targetWorkspaceId = String(target.workspaceId || workspacePath.workspaceId || '').trim(); - var currentWorkspaceId = String(resolveWorkspaceId(documentRef.body) || '').trim(); - if (targetWorkspaceId && currentWorkspaceId && targetWorkspaceId !== currentWorkspaceId) { - var workspaceError = new Error('AI target 与当前 workspaceId 不一致,请重新选择当前工作区内的目标。'); - workspaceError.code = 'page_ai_target_workspace_mismatch'; - throw workspaceError; - } - } - - function pageAiBuildContextRefs(scopedContext, runTargetSnapshot) { - var selected = pageAiEnsureContextRefState(); - var refs = []; - var documentId = currentDocumentId(); - var rootUri = currentRootUri(); - var workspaceId = resolveWorkspaceId(documentRef.body); - var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget(); - if (selected.current_page) { - refs.push({ - kind: 'current_page', - documentId: documentId, - rootUri: rootUri, - workspaceId: workspaceId - }); - } - if (selected.selection && scopedContext && scopedContext.selectedText) { - refs.push({ - kind: 'selection', - documentId: documentId, - rootUri: rootUri, - selectedBlockId: scopedContext.selectedBlockId || '' - }); - } - if (selected.active_editor && editorTarget) { - var workspacePath = editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {}; - refs.push({ - kind: 'active_editor', - documentId: editorTarget.documentId || documentId, - workspaceId: editorTarget.workspaceId || workspacePath.workspaceId || workspaceId, - rootUri: workspacePath.rootUri || rootUri, - relativePath: workspacePath.relativePath || '', - editorKind: editorTarget.editorKind || '', - resourceKind: editorTarget.resourceKind || workspacePath.resourceKind || '', - targetId: editorTarget.targetId || workspacePath.objectIdentity || '', - objectIdentity: editorTarget.objectIdentity || workspacePath.objectIdentity || '', - assetId: editorTarget.assetId || workspacePath.assetId || '', - onlyofficeSessionId: editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId || '', - bridgeSessionId: editorTarget.bridgeSessionId || editorTarget.onlyofficeSessionId || '' - }); - } - if (selected.file) { - refs.push({ - kind: 'file', - documentId: documentId, - rootUri: rootUri, - relativePath: localMarkdownRelativePathFromPageAiDocumentId(documentId) - }); - } - if (selected.folder) { - refs.push({ - kind: 'folder', - rootUri: rootUri, - relativePath: '' - }); - } - if (selected.changed_files) { - refs.push({ - kind: 'changed_files', - rootUri: rootUri, - sinceRunTargetSnapshot: runTargetSnapshot && runTargetSnapshot.frozenAt || null - }); - } - return refs.filter(function(ref) { - return ref && String(ref.kind || '').trim(); - }); - } - - function pageAiBuildAllowedRoots() { - return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) { - return { - rootUri: root.rootUri, - permission: root.permission === 'write' || root.permission === 'read_write' ? 'write' : 'read', - recursive: root.recursive !== false, - source: root.source === 'auto' || root.source === 'user' || root.source === 'admin' - ? 'sqlite_directory_grant' - : (root.source || 'sqlite_directory_grant'), - grantId: root.id || '' - }; - }); - } - - function pageAiBuildRunTargetSnapshot(scopedContext, prompt) { - var aiContext = scopedContext && scopedContext.pageContext && scopedContext.pageContext.aiContext - ? scopedContext.pageContext.aiContext - : {}; - var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget(); - return { - schema: 'mnote.page_ai_run_target_snapshot.v1', - source: 'open_editors_snapshot', - frozenAt: Date.now(), - workspaceId: resolveWorkspaceId(documentRef.body), - documentId: currentDocumentId(), - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - contextScope: pageUiState.pageAiContextScope || 'page', - promptPreview: searchText(prompt || '').slice(0, 160), - editorTarget: pageAiCloneJson(editorTarget), - activeEditorTarget: pageAiCloneJson(aiContext.activeEditorTarget || editorTarget), - openEditorsSnapshot: pageAiCloneJson(aiContext.openEditorsSnapshot || currentPageAiOpenEditorsSnapshot()) - }; - } - - function pageAiContextKindsFromRefs(contextRefs) { - var kinds = {}; - pageAiNormalizeArray(contextRefs).forEach(function(ref) { - var kind = String(ref && ref.kind || '').trim(); - if (kind) kinds[kind] = true; - }); - return kinds; - } - - function pageAiPageContextForRefs(pageContext, contextRefs) { - var cloned = pageAiCloneJson(pageContext) || {}; - var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {}; - var kinds = pageAiContextKindsFromRefs(contextRefs); - delete cloned.documentBlocks; - delete cloned.evidence; - delete aiContext.contextBlocks; - delete aiContext.pageText; - delete aiContext.pageXml; - delete aiContext.truncated; - delete aiContext.warnings; - if (!kinds.selection) { - delete aiContext.selectedText; - delete aiContext.selectedBlockIds; - delete aiContext.selectedBlocks; - delete aiContext.allowedTargetBlockIds; - } - cloned.aiContext = aiContext; - return cloned; - } - - function pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot) { - var target = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget(); - var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object' - ? pageAiWorkspacePathForDocument(target.documentId || currentDocumentId(), target.workspacePath) - : pageAiWorkspacePathForDocument(target && target.documentId || currentDocumentId(), null); - var relativePath = String(workspacePath.relativePath || '').trim(); - var allowedFiles = relativePath ? [relativePath] : []; - var writable = pageAiBuildAllowedRoots().some(function(root) { - return String(root && root.rootUri || '').trim() === String(workspacePath.rootUri || '').trim() - && String(root && root.permission || '').trim() === 'write'; - }); - var primaryTargetId = String(target && target.targetId || workspacePath.objectIdentity || workspacePath.documentId || '').trim(); - var onlyofficeSessionId = String(target && (target.onlyofficeSessionId || target.bridgeSessionId) || '').trim(); - var targetEntry = { - targetId: primaryTargetId, - objectIdentity: primaryTargetId, - documentId: workspacePath.documentId, - workspaceId: workspacePath.workspaceId, - sourceKind: workspacePath.sourceKind, - rootUri: workspacePath.rootUri, - relativePath: relativePath, - resourceKind: workspacePath.resourceKind, - assetId: workspacePath.assetId || target && target.assetId || '', - onlyofficeSessionId: onlyofficeSessionId, - bridgeSessionId: onlyofficeSessionId, - paneRole: target && target.paneRole || 'primary', - title: target && target.title || '', - policy: { - permission: allowedFiles.length && writable ? 'read_write' : 'read', - writeRequiresCleanBuffer: true, - conflictPolicy: 'fail_on_dirty_or_stale' - } - }; - return { - schema: 'mnote.agent_target_package.v1', - source: 'page_ai_run_target_snapshot', - frozenAt: runTargetSnapshot && runTargetSnapshot.frozenAt || Date.now(), - primaryTargetId: primaryTargetId, - onlyofficeSessionId: onlyofficeSessionId, - bridgeSessionId: onlyofficeSessionId, - workspaceId: workspacePath.workspaceId, - sourceKind: workspacePath.sourceKind, - rootUri: workspacePath.rootUri, - documentId: workspacePath.documentId, - objectIdentity: primaryTargetId, - resourceKind: workspacePath.resourceKind, - workspacePath: workspacePath, - currentFile: relativePath ? { - rootUri: workspacePath.rootUri, - relativePath: relativePath, - documentId: workspacePath.documentId, - objectIdentity: primaryTargetId, - resourceKind: workspacePath.resourceKind - } : null, - allowedFiles: allowedFiles, - targets: [targetEntry], - policy: { - writeRequiresExplicitTarget: true, - allowedFilesSource: 'selected_page_ai_target', - conflictPolicy: 'fail_on_dirty_or_stale' - } - }; - } - - function pageAiBlockingDirtyState(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 ''; - } - - async function fetchPageAiTargetBufferState(editorTarget) { - if (currentSourceKind() !== 'local_folder') return null; - var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {}; - var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {}; - var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim(); - if (resourceKind === 'office' || resourceKind === 'only_office' || resourceKind === 'onlyoffice') return null; - var documentId = String(target.documentId || currentDocumentId() || '').trim(); - var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim(); - if (!documentId || !rootUri) return null; - var relativePath = String(workspacePath.relativePath || '').trim() - || localMarkdownRelativePathFromPageAiDocumentId(documentId); - 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(target.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim(); - if (workspaceId) url.searchParams.set('workspaceId', workspaceId); - if (relativePath) url.searchParams.set('relativePath', relativePath); - try { - var response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok || !payload || payload.ok !== true) return null; - return payload.result || null; - } catch (_) { - return null; - } - } - - async function assertPageAiTargetWritable(editorTarget) { - var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {}; - var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim(); - var onlyofficeSessionId = String(editorTarget && (editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId) || '').trim(); - if (pageAiIsOnlyOfficeLiveTarget(editorTarget) && !onlyofficeSessionId) { - var sessionError = new Error('ONLYOFFICE 资源仍在连接 MNote bridge,请等待 Office 页面加载完成后再让 AI 操作。'); - sessionError.code = 'page_ai_onlyoffice_bridge_not_ready'; - throw sessionError; - } - var snapshotState = pageAiBlockingDirtyState(editorTarget && editorTarget.dirtyState); - var bufferState = await fetchPageAiTargetBufferState(editorTarget); - var bufferDirtyState = pageAiBlockingDirtyState(bufferState && bufferState.dirtyState); - var blockedState = bufferDirtyState || snapshotState; - if (!blockedState) return bufferState; - var documentId = String(editorTarget && editorTarget.documentId || currentDocumentId() || '').trim(); - var error = new Error('目标文档存在未保存或外部变更状态(' + blockedState + '),请先保存、解决冲突或刷新后再让 AI 写入。'); - error.code = 'page_ai_target_buffer_not_writable'; - error.documentId = documentId; - error.dirtyState = blockedState; - throw error; - } - - function currentPageAiSelectedText() { - try { - var selection = window.getSelection ? window.getSelection() : null; - return selection ? searchText(selection.toString() || '') : ''; - } catch (_) { - return ''; - } - } - - function pageAiProjectionBlocks(aggregate) { - var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks; - return Array.isArray(blocks) ? blocks : []; - } - - function pageAiBlockText(block) { - return searchText(block && (block.text || block.title || block.content) || ''); - } - - function pageAiSelectedBlockIdsFromSelection() { - try { - var selection = window.getSelection ? window.getSelection() : null; - if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return []; - var range = selection.getRangeAt(0); - var editor = documentRef.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror'); - if (!(editor instanceof HTMLElement)) return []; - return Array.from(editor.children).filter(function(node) { - if (!(node instanceof HTMLElement)) return false; - try { - return range.intersectsNode(node); - } catch (_) { - return false; - } - }).map(function(node) { - return searchText(node.getAttribute('data-id') || node.id || ''); - }).filter(Boolean); - } catch (_) { - return []; - } - } - - function pageAiBlocksToPageXml(blocks, aggregate) { - var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : ''; - var pageId = currentDocumentId() || 'current-page'; - var lines = ['']; - blocks.forEach(function(block) { - var blockId = String(block && (block.blockId || block.id) || ''); - var type = String(block && block.type || 'paragraph'); - var revisionRef = String(block && block.revisionRef || ''); - var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : ''; - lines.push(' ' + escapeHtml(pageAiBlockText(block)) + ''); - }); - lines.push(''); - return lines.join('\n'); - } - - function buildPageAiContext(contextSnapshot, scope, selectedText) { - var aggregate = contextSnapshot.aggregate || {}; - var body = aggregate.body || {}; - var allBlocks = pageAiProjectionBlocks(aggregate); - var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : []; - var selectedSet = {}; - selectedBlockIds.forEach(function(id) { selectedSet[id] = true; }); - var selectedBlocks = selectedBlockIds.length - ? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; }) - : []; - var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120); - var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length; - return { - schema: 'mnote.page_ai_context.v1', - workspaceId: resolveWorkspaceId(documentRef.body), - documentId: currentDocumentId(), - activeEditorTarget: currentPageAiScopedEditorTarget(), - openEditorsSnapshot: currentPageAiOpenEditorsSnapshot(), - scope: scope, - revision: body.revision || null, - conflictDetectionKey: body.conflictDetectionKey || null, - selectedText: selectedText || '', - selectedBlockIds: selectedBlockIds, - allowedTargetBlockIds: selectedBlockIds, - selectedBlocks: selectedBlocks, - contextBlocks: contextBlocks, - pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'), - pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate), - truncated: truncated, - warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : [] - }; - } - - function pageAiScopedPageContext(contextSnapshot) { - var aggregate = contextSnapshot.aggregate || {}; - var body = contextSnapshot.body || {}; - var subtree = contextSnapshot.subtree || null; - var outline = subtree && subtree.outline ? subtree.outline : null; - var scope = pageUiState.pageAiContextScope || 'page'; - var title = aggregate.head && aggregate.head.title ? aggregate.head.title : ''; - var selectedText = scope === 'selection' ? currentPageAiSelectedText() : ''; - var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText); - var editorTarget = aiContext.activeEditorTarget || currentPageAiScopedEditorTarget(); - return { - pageContext: { - contextScope: scope, - documentBlocks: null, - node: { - documentId: currentDocumentId(), - title: title - }, - subtree: null, - outline: null, - pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope, - evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null, - pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null, - contentAccess: 'mnote.context.read_current_page', - aiContext: aiContext - }, - editorTarget: editorTarget, - selectedText: selectedText, - selectedBlockId: aiContext.selectedBlockIds[0] || null - }; - } - - return { - currentPageAiSelectedText, - currentPageAiEditorTarget, - currentPageAiPageEditorTarget, - currentPageAiScopedEditorTarget, - currentPageAiOpenEditorsSnapshot, - pageAiBlockText, - pageAiBlockingDirtyState, - pageAiBlocksToPageXml, - pageAiBuildAgentTargetPackage, - pageAiBuildAllowedRoots, - pageAiBuildContextRefs, - pageAiBuildRunTargetSnapshot, - pageAiCloneJson, - pageAiContextKindsFromRefs, - pageAiEditorTargetCandidates, - pageAiFallbackEditorTarget, - pageAiPageContextForRefs, - pageAiProjectionBlocks, - pageAiResourceKindForTarget, - pageAiSelectedBlockIdsFromSelection, - pageAiSetRunTargetSnapshot, - pageAiScopedPageContext, - pageAiTargetFromOpenEditor, - pageAiTargetId, - pageAiWorkspacePathForDocument, - pageAiWorkspacePathForTarget, - assertPageAiTargetInCurrentWorkspace, - assertPageAiTargetWritable, - buildPageAiContext, - fetchPageAiTargetBufferState, - localMarkdownDocumentIdFromPageAiRelativePath, - localMarkdownRelativePathFromPageAiDocumentId, - }; -} diff --git a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js index eb656dd3..29b3aa23 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-settings-runtime.js @@ -599,7 +599,6 @@ export function createSidebarPageSettingsRuntime(context) { '
    ' + '' + '' + - '' + '' + '
    ' + '
    ' + @@ -608,9 +607,6 @@ export function createSidebarPageSettingsRuntime(context) { '' + - '' + '' + @@ -625,17 +621,12 @@ export function createSidebarPageSettingsRuntime(context) { '
    ' + '
    Skills
    ' + '
    --
    ' + - '
    已安装 / 可用
    ' + + '
    工具清单中的 skill 能力
    ' + '
    ' + - '
    ' + - '
    MCP
    ' + - '
    --
    ' + - '
    服务器 / 已连接
    ' + - '
    ' + - '
    ' + - '
    Agent Runs
    ' + - '
    --
    ' + - '
    最近 7 天运行次数
    ' + + '
    ' + + '
    Agent 工具
    ' + + '
    --
    ' + + '
    /api/mnote/tools/manifest
    ' + '
    ' + '
    ' + '' + @@ -3003,10 +2994,29 @@ export function createSidebarPageSettingsRuntime(context) { // 切换时懒加载对应面板数据 if (subtabName === 'knowledge') refreshKnowledgeExtensionsPanel(); else if (subtabName === 'skills') refreshSkillsExtensionsPanel(); - else if (subtabName === 'mcp') refreshMcpExtensionsPanel(); else if (subtabName === 'tools') refreshToolsExtensionsPanel(); } + async function fetchAgentToolsManifest() { + var resp = await fetch('/api/mnote/tools/manifest'); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + var data = await resp.json(); + var tools = Array.isArray(data && data.tools) ? data.tools : []; + var capabilities = data && data.capabilities && typeof data.capabilities === 'object' + ? data.capabilities + : {}; + return { data: data, tools: tools, capabilities: capabilities }; + } + + function isSkillRelatedTool(tool) { + if (!tool || typeof tool !== 'object') return false; + var name = String(tool.name || tool.function_name || ''); + if (/skill/i.test(name)) return true; + var scopes = tool.capabilityScope || tool.capabilities || []; + if (Array.isArray(scopes) && scopes.some(function(s) { return /skill/i.test(String(s)); })) return true; + return false; + } + // 当切换到扩展 tab 时刷新当前子面板 function refreshExtensionsPanel() { var popover = ensurePageSettingsPopover(); @@ -3051,26 +3061,30 @@ export function createSidebarPageSettingsRuntime(context) { } } - // --- Skills 子面板 --- + // --- Skills 子面板(来自 /api/mnote/tools/manifest,非历史 agent 网关) --- async function refreshSkillsExtensionsPanel() { var statusEl = document.querySelector('[data-extensions-status="skills"]'); if (!statusEl) return; statusEl.innerHTML = '加载中...'; try { - var resp = await fetch('/api/page-ai/agent-descriptors'); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - var data = await resp.json(); - var descriptors = data.descriptors || data || []; - // 从 agent descriptor 中收集 skills 信息 + var pack = await fetchAgentToolsManifest(); var allSkills = []; - descriptors.forEach(function(desc) { - var caps = desc.capabilities || desc.capabilityStates || {}; - if (caps.skills && Array.isArray(caps.skills)) { - caps.skills.forEach(function(s) { allSkills.push({ name: s, source: desc.name || desc.id || 'unknown' }); }); - } + pack.tools.forEach(function(tool) { + if (!isSkillRelatedTool(tool)) return; + allSkills.push({ + name: tool.name || tool.function_name || 'skill', + source: 'mnote-agent-tools' + }); + }); + var caps = pack.capabilities; + Object.keys(caps).forEach(function(key) { + if (!/skill/i.test(key)) return; + var entry = caps[key]; + var label = entry && (entry.name || entry.id) ? (entry.name || entry.id) : key; + allSkills.push({ name: String(label), source: 'capability' }); }); if (!allSkills.length) { - allSkills.push({ name: 'skill-read (内置)', source: 'hermes' }); + allSkills.push({ name: 'mnote.skill.read', source: 'builtin' }); } var html = ''; allSkills.forEach(function(skill) { @@ -3078,63 +3092,29 @@ export function createSidebarPageSettingsRuntime(context) { '
    ' + escapeHtml(skill.name) + '' + escapeHtml(skill.source) + '
    ' + '
    '; }); - if (!html) html = '
    暂无已安装 Skills
    '; + if (!html) html = '
    暂无 Skills
    '; statusEl.innerHTML = html; } catch (err) { statusEl.innerHTML = '
    无法加载 Skills 信息
    '; } } - // --- MCP 子面板 --- - async function refreshMcpExtensionsPanel() { - var statusEl = document.querySelector('[data-extensions-status="mcp"]'); - if (!statusEl) return; - statusEl.innerHTML = '加载中...'; - try { - var resp = await fetch('/api/hermes/mcp/servers'); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - var servers = await resp.json(); - var list = Array.isArray(servers) ? servers : (servers.servers || []); - var html = ''; - if (list.length === 0) { - html = '
    暂无已配置 MCP 服务器
    '; - } else { - list.forEach(function(srv) { - var name = srv.name || srv.id || '未命名'; - var url = srv.url || srv.command || ''; - var connected = srv.connected ? '已连接' : '未连接'; - var connClass = srv.connected ? 'mnote-status-ready' : 'mnote-status-pending'; - html += '
    ' + - '
    ' + escapeHtml(name) + '' + connected + '
    ' + - (url ? '
    ' + escapeHtml(String(url)) + '
    ' : '') + - '
    '; - }); - } - statusEl.innerHTML = html; - } catch (err) { - statusEl.innerHTML = '
    无法加载 MCP 配置
    '; - } - } - // --- Agent 工具子面板 --- async function refreshToolsExtensionsPanel() { var statusEl = document.querySelector('[data-extensions-status="tools"]'); if (!statusEl) return; statusEl.innerHTML = '加载中...'; try { - var resp = await fetch('/api/page-ai/agent-descriptors'); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - var data = await resp.json(); - var descriptors = data.descriptors || data || []; + var pack = await fetchAgentToolsManifest(); var html = ''; - descriptors.forEach(function(desc) { - var tools = desc.tools || []; - var name = desc.name || desc.id || 'unknown'; - if (!tools.length) return; + if (!pack.tools.length) { + html = '
    暂无 Agent 工具信息
    '; + } else { html += '
    ' + - '
    ' + escapeHtml(name) + '
    '; - tools.forEach(function(tool) { + '
    mnote agent tools
    '; + pack.tools.forEach(function(tool) { var toolName = tool.name || tool.function_name || ''; + if (!toolName) return; var disabled = tool.disabled ? ' (已禁用)' : ''; html += '
    ' + '' + escapeHtml(toolName) + '' + @@ -3142,8 +3122,7 @@ export function createSidebarPageSettingsRuntime(context) { '
    '; }); html += '
    '; - }); - if (!html) html = '
    暂无 Agent 工具信息
    '; + } statusEl.innerHTML = html; } catch (err) { statusEl.innerHTML = '
    无法加载 Agent 工具信息
    '; @@ -3371,14 +3350,9 @@ export function createSidebarPageSettingsRuntime(context) { // --- 仪表盘 --- function refreshDashboardPanel() { var popover = ensurePageSettingsPopover(); - // 知识库状态 refreshDashboardKnowledgeCard(popover); - // Skills refreshDashboardSkillsCard(popover); - // MCP - refreshDashboardMcpCard(popover); - // Agent runs - refreshDashboardAgentCard(popover); + refreshDashboardToolsCard(popover); } async function refreshDashboardKnowledgeCard(popover) { @@ -3408,49 +3382,22 @@ export function createSidebarPageSettingsRuntime(context) { if (!el) return; el.textContent = '加载中...'; try { - var resp = await fetch('/api/page-ai/agent-descriptors'); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - var data = await resp.json(); - var descriptors = data.descriptors || data || []; - var count = 0; - descriptors.forEach(function(d) { - var caps = d.capabilities || d.capabilityStates || {}; - if (caps.skills && Array.isArray(caps.skills)) count += caps.skills.length; - }); - if (!count) count = 1; // skill-read 内置 + var pack = await fetchAgentToolsManifest(); + var count = pack.tools.filter(isSkillRelatedTool).length; + if (!count) count = 1; // mnote.skill.read 内置 el.textContent = count + ' 个'; } catch (_) { el.textContent = '--'; } } - async function refreshDashboardMcpCard(popover) { - var el = popover.querySelector('[data-dashboard-value="mcp"]'); + async function refreshDashboardToolsCard(popover) { + var el = popover.querySelector('[data-dashboard-value="tools"]'); if (!el) return; el.textContent = '加载中...'; try { - var resp = await fetch('/api/hermes/mcp/servers'); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - var servers = await resp.json(); - var list = Array.isArray(servers) ? servers : (servers.servers || []); - var connected = list.filter(function(s) { return s.connected; }).length; - el.textContent = list.length + ' / ' + connected + ' 已连接'; - } catch (_) { - el.textContent = '--'; - } - } - - async function refreshDashboardAgentCard(popover) { - var el = popover.querySelector('[data-dashboard-value="agent"]'); - if (!el) return; - el.textContent = '加载中...'; - try { - var resp = await fetch('/api/hermes/client/runs?limit=100'); - if (!resp.ok) throw new Error('HTTP ' + resp.status); - var data = await resp.json(); - var runs = Array.isArray(data) ? data : (data.runs || []); - var recentCount = runs.length; - el.textContent = recentCount + ' 次'; + var pack = await fetchAgentToolsManifest(); + el.textContent = pack.tools.length + ' 个'; } catch (_) { el.textContent = '--'; } diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index 9b01d4e3..c494006e 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -2982,61 +2982,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim openSearchModal(); }, true); - const sidebarPageAi = createSidebarPageAiRuntime({ - buildLocalFileOpenUrl, - currentDocumentId, - currentPageAggregate, - currentPageOptions, - currentRootUri, - currentSourceKind, - escapeHtml, - cssEscape, - openLocalResourceInActiveTab, - pageUiState, - resolveWorkspaceId, - searchText, - }); + // Page AI 产品入口仅 Pi Lab;facade 只桥接 open / trigger / no-op delegates。 + const sidebarPageAi = createSidebarPageAiRuntime({ pageUiState }); const openPageAiDrawer = (...args) => sidebarPageAi.openPageAiDrawer(...args); - const closePageAiDrawer = (...args) => sidebarPageAi.closePageAiDrawer(...args); - const isPageAiDrawerOpen = (...args) => sidebarPageAi.isPageAiDrawerOpen(...args); - const ensurePageAiDrawer = (...args) => sidebarPageAi.ensurePageAiDrawer(...args); - const sendPageAiMessage = (...args) => sidebarPageAi.sendPageAiMessage(...args); - const pageAiOpenHermesSettings = (...args) => sidebarPageAi.pageAiOpenHermesSettings(...args); - const pageAiStopRun = (...args) => sidebarPageAi.pageAiStopRun(...args); - const pageAiLoadGatewayHealth = (...args) => sidebarPageAi.pageAiLoadGatewayHealth(...args); - const renderPageAiControls = (...args) => sidebarPageAi.renderPageAiControls(...args); - const renderPageAiConversation = (...args) => sidebarPageAi.renderPageAiConversation(...args); - const renderPageAiProviderButtons = (...args) => sidebarPageAi.renderPageAiProviderButtons(...args); - const renderPageAiSuggestions = (...args) => sidebarPageAi.renderPageAiSuggestions(...args); - const pageAiSaveProfileMemory = (...args) => sidebarPageAi.pageAiSaveProfileMemory(...args); - const pageAiToggleSkill = (...args) => sidebarPageAi.pageAiToggleSkill(...args); - const pageAiToggleTool = (...args) => sidebarPageAi.pageAiToggleTool(...args); - const pageAiResumeBackendSession = (...args) => sidebarPageAi.pageAiResumeBackendSession(...args); - const pageAiRenameBackendSession = (...args) => sidebarPageAi.pageAiRenameBackendSession(...args); - const pageAiDeleteBackendSession = (...args) => sidebarPageAi.pageAiDeleteBackendSession(...args); - const pageAiResolvePermission = (...args) => sidebarPageAi.pageAiResolvePermission(...args); - const pageAiOpenLocation = (...args) => sidebarPageAi.pageAiOpenLocation(...args); - const pageAiSetActiveSession = (...args) => sidebarPageAi.pageAiSetActiveSession(...args); - const pageAiStartNewSession = (...args) => sidebarPageAi.pageAiStartNewSession(...args); - const pageAiLoadSessions = (...args) => sidebarPageAi.pageAiLoadSessions(...args); - const pageAiLoadBackendSessions = (...args) => sidebarPageAi.pageAiLoadBackendSessions(...args); - const pageAiCancelQueuedRun = (...args) => sidebarPageAi.pageAiCancelQueuedRun(...args); - const pageAiSearchBackendSessions = (...args) => sidebarPageAi.pageAiSearchBackendSessions(...args); - const pageAiPersistSessions = (...args) => sidebarPageAi.pageAiPersistSessions(...args); - const pageAiLoadProfiles = (...args) => sidebarPageAi.pageAiLoadProfiles(...args); - const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args); - const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args); - const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args); - const pageAiSetSkillSource = (...args) => sidebarPageAi.pageAiSetSkillSource(...args); - const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args); - const pageAiSetReasonixMemoryEnabled = (...args) => sidebarPageAi.pageAiSetReasonixMemoryEnabled(...args); - const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args); - const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args); - const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args); - const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args); - const pageAiSetAgentPopoverOpen = (...args) => sidebarPageAi.pageAiSetAgentPopoverOpen(...args); - const pageAiSetTargetPopoverOpen = (...args) => sidebarPageAi.pageAiSetTargetPopoverOpen(...args); - const pageAiSelectTarget = (...args) => sidebarPageAi.pageAiSelectTarget(...args); const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args); const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({ @@ -3374,7 +3322,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim var pageAiTrigger = closestAction(e.target, '[data-mnote-action="open-page-ai"]'); if (pageAiTrigger) { e.preventDefault(); - openPageAiDrawer(); + // 产品面唯一 Page AI:Pi Lab(不再打开 Hermes/OpenCode legacy drawer) + if (window.createSidebarPageAiPiLabRuntime) { + window.createSidebarPageAiPiLabRuntime({}); + } + window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, window.location.origin); return; } diff --git a/rust/crates/mnote-web/browser/vault-workbench-runtime.js b/rust/crates/mnote-web/browser/vault-workbench-runtime.js index a06d8da7..d641453a 100644 --- a/rust/crates/mnote-web/browser/vault-workbench-runtime.js +++ b/rust/crates/mnote-web/browser/vault-workbench-runtime.js @@ -163,12 +163,17 @@ function ensureFormUi() { if (!state._formUi) { - state._formUi = { openAccountIds: {}, expandedSecretPanels: {} }; + state._formUi = { + openAccountIds: {}, + expandedSecretPanels: {}, + folderPickerOpen: {}, + }; } if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {}; if (!state._formUi.expandedSecretPanels) { state._formUi.expandedSecretPanels = {}; } + if (!state._formUi.folderPickerOpen) state._formUi.folderPickerOpen = {}; return state._formUi; } @@ -892,6 +897,8 @@ kind: (s && s.kind) || 'apikey', label: (s && s.label) || '', value: (s && s.value) || { state: 'absent' }, + // Draft-only flag from reRenderFormKeepingSlots; absent on server items. + valueClear: !!(s && s.valueClear), accountId: (s && (s.accountId || s.account_id)) || '', }; } @@ -924,6 +931,96 @@ return out; } + /** 将 ISO 时间格式化为本地可读短串;无效则原样返回。 */ + function formatVaultDateTime(raw) { + var s = String(raw || '').trim(); + if (!s) return ''; + var d = new Date(s); + if (isNaN(d.getTime())) return s; + try { + return d.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } catch (_e) { + return s; + } + } + + /** + * 账号级登录态展示(扩展 Cookie session)。 + * 优先账号槽位字段;合成单账号时回退条目级 hasLoginSession / sessionUpdatedAt。 + */ + function accountSessionView(acc, item, isSyntheticSingle) { + var has = + acc && typeof acc.hasLoginSession === 'boolean' + ? acc.hasLoginSession + : isSyntheticSingle && item + ? !!item.hasLoginSession + : false; + var savedAt = + (acc && (acc.lastLoginAt || acc.sessionUpdatedAt)) || + (isSyntheticSingle && item + ? item.lastLoginAt || item.sessionUpdatedAt || '' + : '') || + ''; + var expiresAt = + (acc && acc.sessionExpiresAt) || + (isSyntheticSingle && item ? item.sessionExpiresAt || '' : '') || + ''; + return { + hasLoginSession: !!has, + savedAt: String(savedAt || '').trim(), + expiresAt: String(expiresAt || '').trim(), + source: (acc && acc.sessionSource) || '', + }; + } + + /** 详情页:账号下「是否保存登录态 + 保存日期」行。 */ + function renderAccountSessionRows(sess, showEmpty) { + if (!sess.hasLoginSession && !showEmpty) { + // 未保存时也简短展示一行,避免用户以为功能缺失 + return ( + '
    ' + + '' + + '
    未保存
    ' + + '
    ' + ); + } + var badge = sess.hasLoginSession + ? '已保存' + : '未保存'; + var dateText = sess.hasLoginSession + ? formatVaultDateTime(sess.savedAt) || '—' + : '—'; + var html = + '
    ' + + '' + + '
    ' + + badge + + '
    ' + + '
    ' + + '' + + '
    ' + + (sess.hasLoginSession + ? escapeHtml(dateText) + : '') + + '
    '; + if (sess.hasLoginSession && sess.expiresAt) { + html += + '
    ' + + '' + + '
    ' + + escapeHtml(formatVaultDateTime(sess.expiresAt) || sess.expiresAt) + + '
    '; + } + return html; + } + function normalizeAccountsFromItem(item, forEdit) { var topSecrets = normalizeSecretsFromItem(item); var accounts = (item && item.accounts) || []; @@ -941,6 +1038,25 @@ return s.accountId && s.accountId === a.id; }); } + // 多账号时:若槽位未挂 session 元数据,首账号可回退条目级(兼容旧投影) + var hasSess = + typeof a.hasLoginSession === 'boolean' + ? a.hasLoginSession + : aidx === 0 + ? !!(item && item.hasLoginSession) + : false; + var lastLogin = + a.lastLoginAt || + (aidx === 0 && item ? item.lastLoginAt : null) || + null; + var sessUpdated = + a.sessionUpdatedAt || + (aidx === 0 && item ? item.sessionUpdatedAt : null) || + null; + var sessExpires = + a.sessionExpiresAt || + (aidx === 0 && item ? item.sessionExpiresAt : null) || + null; return { id: a.id || '', label: a.label || '', @@ -952,7 +1068,14 @@ : a.email || '', passwordHint: a.passwordHint || '', password: a.password || { state: 'absent' }, + // Draft-only flag from reRenderFormKeepingSlots; absent on server items. + passwordClear: !!a.passwordClear, secrets: nested, + hasLoginSession: hasSess, + lastLoginAt: lastLogin, + sessionUpdatedAt: sessUpdated, + sessionExpiresAt: sessExpires, + sessionSource: a.sessionSource || '', }; }); } @@ -974,6 +1097,13 @@ passwordHint: (item && item.passwordHint) || '', password: (item && item.password) || { state: 'absent' }, secrets: topSecrets, + // 合成单账号:登录态取条目级 + hasLoginSession: !!(item && item.hasLoginSession), + lastLoginAt: (item && item.lastLoginAt) || null, + sessionUpdatedAt: (item && item.sessionUpdatedAt) || null, + sessionExpiresAt: (item && item.sessionExpiresAt) || null, + sessionSource: '', + _syntheticFromItem: true, }, ]; } @@ -986,6 +1116,12 @@ passwordHint: '', password: { state: 'absent' }, secrets: [], + hasLoginSession: !!(item && item.hasLoginSession), + lastLoginAt: (item && item.lastLoginAt) || null, + sessionUpdatedAt: (item && item.sessionUpdatedAt) || null, + sessionExpiresAt: (item && item.sessionExpiresAt) || null, + sessionSource: '', + _syntheticFromItem: true, }, ]; } @@ -1157,6 +1293,11 @@ acc.username || acc.email || '账号 ' + (idx + 1); + var sess = accountSessionView( + acc, + item, + !!acc._syntheticFromItem || accounts.length === 1 + ); var body = fieldRow('标签名', acc.label, showEmpty) + fieldRow('用户名', acc.username, showEmpty) + @@ -1164,7 +1305,8 @@ secretRow('密码', 'password', acc.password, showEmpty, { accountId: acc.id || '', }) + - fieldRow('密码提示', acc.passwordHint, showEmpty); + fieldRow('密码提示', acc.passwordHint, showEmpty) + + renderAccountSessionRows(sess, showEmpty); var secList = acc.secrets || []; var nestedSecretsHtml = secList .map(function (sec, sidx) { @@ -1220,6 +1362,9 @@ (secList.length ? '密钥 ' + secList.length + '' : '') + + (sess.hasLoginSession + ? '登录态' + : '') + '' + '
    ' + body + @@ -1329,7 +1474,7 @@ ); } - /** Unique folderPath values from current list (for select / datalist). */ + /** Unique folderPath values from current list (for picker / datalist). */ function collectFolderPaths() { var set = {}; (state.items || []).forEach(function (item) { @@ -1346,47 +1491,152 @@ }); } + /** + * Nested tree of known folderPath segments for the expand-style picker. + * @returns {{ name: string, path: string, children: Array }} + */ + function buildFolderPickerTree(paths) { + var root = { name: '', path: '', children: [] }; + var byPath = { '': root }; + (paths || []).forEach(function (fp) { + var parts = String(fp || '') + .split('/') + .filter(Boolean); + var acc = []; + var parent = root; + parts.forEach(function (part) { + acc.push(part); + var p = acc.join('/'); + if (!byPath[p]) { + var node = { name: part, path: p, children: [] }; + byPath[p] = node; + parent.children.push(node); + } + parent = byPath[p]; + }); + }); + function sortNodes(nodes) { + nodes.sort(function (a, b) { + return a.name.localeCompare(b.name, 'zh'); + }); + nodes.forEach(function (n) { + if (n.children && n.children.length) sortNodes(n.children); + }); + } + sortNodes(root.children); + return root; + } + + /** + * Whether a picker branch starts expanded. + * Default: all collapsed. Only expand ancestors of the current selection + * (so the selected path stays visible), or branches the user toggled open. + */ + function isFolderPickerBranchOpen(path, depth, current) { + var ui = ensureFormUi(); + if (!ui.folderPickerOpen) ui.folderPickerOpen = {}; + if (Object.prototype.hasOwnProperty.call(ui.folderPickerOpen, path)) { + return !!ui.folderPickerOpen[path]; + } + // Only auto-open ancestors of the selected path (not the leaf itself unless it has kids under selection). + if (current && path && current.indexOf(path + '/') === 0) { + return true; + } + return false; + } + + function renderFolderPickerNode(node, depth, current) { + var kids = node.children || []; + var hasKids = kids.length > 0; + var selected = current === node.path; + var open = hasKids && isFolderPickerBranchOpen(node.path, depth, current); + var html = ''; + html += + '
    '; + html += '
    '; + if (hasKids) { + html += + ''; + } else { + html += ''; + } + html += + ''; + html += '
    '; + if (hasKids) { + html += + '
    '; + kids.forEach(function (child) { + html += renderFolderPickerNode(child, depth + 1, current); + }); + html += '
    '; + } + html += '
    '; + return html; + } + function formFolderPathField(value) { - var current = (value || '').trim(); + var current = normalizeFolderPath(value || ''); var paths = collectFolderPaths(); var seen = {}; paths.forEach(function (p) { seen[p] = true; }); - var options = - ''; - paths.forEach(function (p) { - options += - ''; - }); + // 当前值若不在列表中,仍挂到树上便于高亮/再选。 + var treePaths = paths.slice(); if (current && !seen[current]) { - options += - ''; + treePaths.push(current); + var parts = current.split('/').filter(Boolean); + for (var i = 1; i < parts.length; i++) { + var prefix = parts.slice(0, i).join('/'); + if (treePaths.indexOf(prefix) < 0) treePaths.push(prefix); + } + } + var tree = buildFolderPickerTree(treePaths); + var treeHtml = ''; + treeHtml += + ''; + tree.children.forEach(function (child) { + treeHtml += renderFolderPickerNode(child, 0, current); + }); + if (!tree.children.length && !current) { + treeHtml += + '
    暂无已有分组,可在下方输入新建
    '; } return ( '
    ' + '' + '
    ' + - '' + + '
    ' + + treeHtml + + '
    ' + '' + + '" placeholder="点上方分组选择,或直接输入;用 / 分层" ' + + 'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" autocomplete="off" />' + '' + paths .map(function (p) { @@ -1394,39 +1644,75 @@ }) .join('') + '' + + '

    分组默认折叠;点 ▶ 展开,点名称即可选中。也可在下方直接输入路径。

    ' + '
    ' ); } + function syncFolderPickerSelection(form, path) { + if (!form) return; + var picker = qs('[data-vault-folder-picker]', form); + if (!picker) return; + var normalized = normalizeFolderPath(path || ''); + qsa('[data-vault-folder-pick]', picker).forEach(function (btn) { + var p = btn.getAttribute('data-vault-folder-pick'); + if (p == null) p = ''; + var row = btn.closest('.mnote-vault-folder-picker-row'); + var selected = p === normalized; + btn.classList.toggle('is-selected', selected); + if (row) row.classList.toggle('is-selected', selected); + if (btn.classList.contains('mnote-vault-folder-picker-none')) { + btn.classList.toggle('is-selected', selected); + } + }); + } + 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; + var picker = qs('[data-vault-folder-picker]', form); + if (picker) { + picker.addEventListener('click', function (ev) { + var t = ev.target; + if (!(t instanceof Element)) return; + var toggle = t.closest('[data-vault-folder-picker-toggle]'); + if (toggle && picker.contains(toggle)) { + ev.preventDefault(); + var tpath = toggle.getAttribute('data-vault-folder-picker-toggle') || ''; + var nodeEl = toggle.closest('.mnote-vault-folder-picker-node'); + // First matching descendants is the direct children panel for this node. + var kidsEl = + nodeEl && nodeEl.querySelector('.mnote-vault-folder-picker-children'); + var open = toggle.getAttribute('aria-expanded') === 'true'; + var next = !open; + ensureFormUi().folderPickerOpen = ensureFormUi().folderPickerOpen || {}; + ensureFormUi().folderPickerOpen[tpath] = next; + toggle.setAttribute('aria-expanded', next ? 'true' : 'false'); + toggle.textContent = next ? '▼' : '▶'; + toggle.setAttribute('title', next ? '收起' : '展开'); + if (kidsEl) kidsEl.hidden = !next; + return; + } + var pick = t.closest('[data-vault-folder-pick]'); + if (pick && picker.contains(pick)) { + ev.preventDefault(); + var chosen = pick.getAttribute('data-vault-folder-pick'); + if (chosen == null) chosen = ''; + chosen = normalizeFolderPath(chosen); + if (input) input.value = chosen; + syncFolderPickerSelection(form, chosen); + 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; - } + syncFolderPickerSelection(form, input.value); }); input.addEventListener('change', function () { var n = normalizeFolderPath(input.value); input.value = n; + syncFolderPickerSelection(form, n); state.dirty = true; }); } @@ -1439,24 +1725,55 @@ var urls = normalizeUrlsFromItem(item); if (!urls.length) urls = ['']; state._formUrls = urls.slice(); + /** + * Rebuild draft slots from item projection. + * reRenderFormKeepingSlots injects in-progress plaintext as + * `{ state: 'revealed', value }` — must become valueText/passwordValue, + * not be wiped to '' (that made the 1st new secret disappear after +密钥). + * valueClear/passwordClear on the pseudo-item are also preserved. + */ state._formAccounts = accounts.map(function (a) { + var pwd = a.password || { state: 'absent' }; + var passwordClear = !!a.passwordClear; + var passwordValue = ''; + var passwordState = pwd; + if (passwordClear) { + passwordState = { state: 'absent' }; + passwordValue = ''; + } else if (pwd && pwd.state === 'revealed') { + // Draft plaintext typed in this session (not yet saved / mid re-render). + passwordValue = String(pwd.value || ''); + // Treat as "no stored secret yet" for keep-logic; save uses passwordValue. + passwordState = { state: 'absent' }; + } return { id: a.id || newClientId('acc'), label: a.label || '', username: a.username || '', email: a.email || '', passwordHint: a.passwordHint || '', - passwordState: a.password || { state: 'absent' }, - passwordClear: false, - passwordValue: '', + passwordState: passwordState, + passwordClear: passwordClear, + passwordValue: passwordValue, secrets: (a.secrets || []).map(function (s) { + var v = s.value || { state: 'absent' }; + var valueClear = !!s.valueClear; + var valueText = ''; + var valueState = v; + if (valueClear) { + valueState = { state: 'absent' }; + valueText = ''; + } else if (v && v.state === 'revealed') { + valueText = String(v.value || ''); + valueState = { state: 'absent' }; + } return { id: s.id || newClientId('sec'), kind: s.kind || 'apikey', label: s.label || '', - valueState: s.value || { state: 'absent' }, - valueClear: false, - valueText: '', + valueState: valueState, + valueClear: valueClear, + valueText: valueText, accountId: a.id || '', }; }), @@ -1592,9 +1909,11 @@ '
    ' + '显示' + + '"' + + (acc.passwordValue ? ' data-shown="1"' : '') + + ' data-testid="vault-acc-show-password">' + + (acc.passwordValue ? '隐藏' : '显示') + + '' + '' @@ -1624,6 +1947,7 @@ sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key'; var title = sec.label || kindLabel + ' ' + (idx + 1); var hasVal = sec.valueState && sec.valueState.state === 'masked'; + var draftText = sec.valueText || ''; return ( '
    ' + (hasVal ? '' + + '"' + + (draftText ? ' data-shown="1"' : '') + + ' data-testid="vault-sec-show-value">' + + (draftText ? '隐藏' : '显示') + + '' + '