diff --git a/design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md b/design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md new file mode 100644 index 00000000..77fb9897 --- /dev/null +++ b/design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md @@ -0,0 +1,456 @@ +# 7-40 Page AI MNote skill library and context tool contract v1 + +> 创建时间:2026-05-29 +> +> 状态:`PROCESS` +> +> Owner:Page AI skill/tool capability surface + MNote host-side context provider +> +> 上位依据: +> - `design/07-ai/done/7-39-page-ai-agent-selector-context-authorization-settings-v1.md` +> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` +> - `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` +> - `design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md` +> - `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md` +> +> 参考代码: +> - `reference-code/PilotDeck/src/context/prompt/PromptAssembler.ts` +> - `reference-code/PilotDeck/src/context/extension/ExtensionResolver.ts` +> - `reference-code/PilotDeck/src/tool/builtin/readSkill.ts` +> - `reference-code/PilotDeck/src/context/input/InputProcessor.ts` +> +> 关键口径:Hermes 和 Reasonix 本身已经是完整 agent,拥有自己的记忆、会话隔离、agent loop、工具调度和运行时状态。MNote 不建设第三套 agent runtime,不把 MNote 上下文、工具说明或页面正文强行注入每条用户 prompt。MNote 要提供一个专用 skill library 和结构化 context tools,让 agent 在需要时自行选择读取。 + +## 1. 背景与问题 + +`7-39` 已经完成第一轮 Page AI UI 收口: + +- `sidebar-page-ai-runtime.js` 已有 `PAGE_AI_AGENT_REGISTRY`。 +- `PAGE_AI_CONTEXT_REF_REGISTRY` 已包含 `current_page / selection / active_editor / file / folder / changed_files`。 +- 发送 `/api/hermes/client/runs` 时 payload 已包含 `agentId`、`contextRefs`、`allowedRoots`、`editorTarget`、`runTargetSnapshot`。 +- SQLite directory grants 和 user preferences 已作为授权与偏好控制面。 + +但当前暴露出一个更关键的系统问题: + +1. 简单聊天请求,例如“收到请回复收到”,仍可能触发大量 `mnote_doc_fetch` 等工具调用。 +2. 当前实现倾向把 MNote page context、tool guidance、runTargetSnapshot 等内容拼成大段 instructions 注入上游 prompt。 +3. 这种方式会诱导 agent 把所有请求都理解成 MNote 文档任务,也会增加 token、工具误调用和失败噪音。 +4. UI 上 contextRefs 与授权区域已经可勾选,但后台语义仍混杂:勾选范围被当成“必须塞给模型的上下文”,而不是“允许 agent 按需读取的能力边界”。 + +因此本稿替代原先以 `AgentRunEnvelope` 为中心的方案。`AgentRunEnvelope` 仍保留为 audit / permission / receipt 的宿主侧结构,但不再是默认 prompt 注入中心。 + +## 2. PilotDeck 对照结论 + +### 2.1 可借鉴模型 + +PilotDeck 的 `PromptAssembler` 采用分层上下文: + +- `ExtensionResolver.listSkills()` 只返回 skill 名称、描述和 namespace。 +- `formatSkills()` 只生成 `` 摘要,并明确提示 `Use the read_skill tool to load the full content of any skill listed below.` +- skill 正文不默认进入每轮 prompt。 +- slash command 由 `InputProcessor` 识别后作为用户意图进入 agent loop,不把所有 command body 默认注入。 +- MCP instructions 也是单独 `` 块,不与用户原文混写。 + +这些模型适合 MNote: + +- Page AI 只暴露“MNote 有哪些能力”。 +- 详细技能说明通过 `mnote_skill.read` 或 agent 原生 skill 读取。 +- 当前页、选区、文件夹、changed files 通过 context tools 按需读取。 +- 用户 prompt 保持干净,避免把普通聊天污染成文档任务。 + +### 2.2 只作参考实现 + +PilotDeck 的完整 RouterRuntime、Model Canonical Protocol、AgentLoop、MemoryResolver 和 CompactionEngine 不直接移植。MNote 当前已有 Hermes / Reasonix ACP runtime,不应复制第三套 agent runtime。 + +可参考但不直接搬运: + +- `ExtensionResolver` 的只读 contribution snapshot。 +- `read_skill` 懒加载模式。 +- tool result budget / tool_result_reference 思路。 +- permission runtime 的统一决策链。 + +### 2.3 不适合 MNote 的内容 + +- 不做 PilotDeck 式全局模型 router。 +- 不做 PilotDeck 式后台 always-on agent runtime。 +- 不做独立于 SQLite control-plane 的另一套权限真相。 +- 不用定时轮询来刷新 Page AI、文件树或页面树。 +- 不把 PilotDeck 的项目 memory dataDir 作为 MNote 默认记忆存储;MNote 若后续做记忆,应按 `workspace_id + user_id` 落 SQLite control-plane 或明确的本地数据目录。 + +## 3. 第一结论 + +本阶段目标从“把 envelope 注入得更干净”改为: + +1. 建立 MNote 专用 skill library 合同。 +2. 建立 MNote context tools 合同。 +3. Page AI 发送时保留用户原文,不把 MNote context/tool guidance 强行拼到 prompt_blocks。 +4. `contextRefs` 只作为用户授权的上下文范围,agent 需要时自行调用工具读取。 +5. `AgentRunEnvelope` 只作为宿主侧 audit / permission / tool input snapshot,不默认进入 prompt。 +6. Chat-only agent 不挂载 MNote context tools;Hermes / Reasonix 是否暴露 MNote tools 由用户勾选的 `contextRefs` 与授权边界决定,普通消息是否调用工具由 agent 自己判断。 +7. UI 继续按 7-39 的 Cline 式模型收敛:输入区只保留 `+ / Agent / 上下文 / 停止 / 发送`,不显示 `write · /path` 黑色授权提示。 + +## 4. 产品设计:Page AI 输入区 + +### 4.1 默认输入区 + +Page AI 输入区默认只显示: + +```text +[+] [Agent] [上下文] [停止] [发送] +``` + +要求: + +- `Agent` 是按钮 + popover,不在输入区平铺 Hermes / Reasonix / Chat-only chip。 +- `上下文` 是按钮 + popover,不在输入区平铺 6 个 context chip。 +- 授权区域只在上下文 popover 中展示,不再在输入区显示 `write · /mnt/Data1T/mnote` 黑色提示。 +- 输入区不显示 profile、model、gateway、runtime、tool trace 等技术噪音。 + +### 4.2 Agent popover + +Agent popover 显示: + +```text +选择 Agent + +(x) Reasonix + 适合工作区任务和文件编辑 + +( ) Hermes + 适合 MNote 内置工具和页面操作 + +( ) Chat-only + 只聊天,不申请文件读写能力 +``` + +选择 agent 后: + +- payload 显式携带 `agentId`。 +- `chat_only` 强制不挂载 MNote context tools。 +- Hermes / Reasonix 可挂载 skill/tool capability,但是否读取由 agent 决定。 + +### 4.3 Context popover + +Context popover 显示: + +```text +发送给 AI 的上下文权限 + +[x] 当前页 + 允许 agent 按需读取当前 Markdown 页面 + +[ ] 选区 + 当前没有选区 + +[x] 打开资源 + 允许 agent 按需读取当前打开资源 + +[ ] 文件 + 允许 agent 按需读取当前文件 + +[ ] 文件夹 + 允许 agent 在授权根内按需读取文件夹 + +[ ] 最近修改 + 允许 agent 读取上次 run 的 changed files 摘要 + +授权区域 + write · /mnt/Data1T/mnote + +[恢复默认] [完成] +``` + +语义: + +- 勾选项表达“允许 agent 读取的上下文范围”,不是“强制把内容发给模型”。 +- `current_page` 勾选后,只代表工具可读取当前页;页面正文不默认塞进 prompt。 +- `selection` 勾选且有选区时,agent 可通过 tool 读取选区文本;选区文本不默认塞进 prompt,除非后续用户明确选择“直接附加选区文本”模式。 +- `folder` 必须由服务端 SQLite grants 重算 allowed roots。 + +## 5. MNote Skill Library 合同 + +### 5.1 Skill registry + +新增 MNote 专用 skill registry,第一阶段可以用静态文件或 Rust 静态 registry,后续可落 SQLite / filesystem。 + +推荐初始技能: + +| skill id | 简述 | 默认 agent | +| --- | --- | --- | +| `mnote-current-page` | 如何读取当前页、判断 dirty/conflict、刷新页面 | Hermes / Reasonix | +| `mnote-selection` | 如何读取和使用选区 | Hermes / Reasonix | +| `mnote-local-file` | 如何解析 MNote target / allowed roots,并让 agent 使用自身文件能力读写本地 Markdown | Reasonix | +| `mnote-attachments` | 如何处理 Markdown 标准链接、图片、PDF/Office 附件 | Hermes / Reasonix | +| `mnote-workspace-search` | 如何在授权 workspace 内搜索文件和引用 | Reasonix | +| `mnote-agent-receipt` | agent 写入后如何报告 changed files 与刷新需求 | Hermes / Reasonix | +| `mnote-chat-only` | 只聊天时不得调用 MNote 文件/页面工具 | Chat-only | + +Skill 元数据: + +```json +{ + "id": "mnote-local-file", + "title": "MNote local file editing", + "description": "Resolve MNote targets and allowed roots before using the agent runtime's native local file tools.", + "agentIds": ["reasonix", "hermes"], + "readOnly": false, + "requiresContextRefs": ["current_page", "file", "folder"], + "toolNames": ["mnote.context.snapshot", "mnote.context.resolve_target"], + "path": "skills/mnote-local-file/SKILL.md" +} +``` + +### 5.2 Prompt 中只出现摘要 + +Page AI 上游 prompt 中最多允许出现类似摘要: + +```xml + +Use mnote_skill.read to load the full content of any skill listed below. +- mnote-current-page — Read the current MNote Markdown page when the task needs page content. +- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots. +- mnote-attachments — Resolve and open Markdown attachment links. + +``` + +禁止: + +- 把每个 skill 的完整正文默认塞入 prompt。 +- 把当前页全文默认塞入 prompt。 +- 把 runTargetSnapshot 的正文、pageXml、contextBlocks 默认塞入 prompt。 +- 把工具长说明拼进用户消息。 + +### 5.3 Skill 读取工具 + +新增或适配工具: + +```json +{ + "name": "mnote_skill.read", + "description": "Read a MNote skill by id when the task requires this MNote capability.", + "input_schema": { + "type": "object", + "properties": { + "skillId": { "type": "string" } + }, + "required": ["skillId"] + } +} +``` + +返回: + +```json +{ + "skillId": "mnote-local-file", + "content": "...SKILL.md...", + "tools": ["mnote_context.resolve_target", "mnote_file.read", "mnote_file.patch"], + "constraints": { + "allowedRootsRequired": true, + "mustReadBackAfterWrite": true + } +} +``` + +## 6. MNote Context Tool 合同 + +### 6.1 工具列表 + +第一阶段建议提供最小工具集: + +| tool | 职责 | +| --- | --- | +| `mnote_context.snapshot` | 返回本次 run 可用上下文摘要,不含正文全文 | +| `mnote_context.read_current_page` | 在 `current_page` 被授权时读取当前页 Markdown | +| `mnote_context.read_selection` | 在 `selection` 被授权且存在时读取选区 | +| `mnote_context.resolve_target` | 返回当前 rootUri、relativePath、documentId、file version | +| `mnote_file.read` | 在 allowed roots 内读取文件 | +| `mnote_file.patch` | 在 allowed roots 内 patch 文件,写入后回读 | +| `mnote_receipt.changed_files` | agent 完成后提交 changed files 或读取 audit 结果 | + +### 6.2 权限原则 + +- 前端 `allowedRoots` 只作为 UI 展示与用户选择,不作为服务端真相。 +- 服务端必须按当前 SQLite user session 重算 grants。 +- 每次 tool 调用都按 `user_id + workspace_id + contextRefs + allowedRoots` 判定。 +- `chat_only` 不注册文件读写工具。 +- 未勾选 `current_page` 时,`mnote_context.read_current_page` 返回 blocked。 +- 未勾选 `folder` 时,`mnote_file.read` 只能读 primary target 或当前文件。 + +### 6.3 上下文快照 + +`mnote_context.snapshot` 返回示例: + +```json +{ + "schema": "mnote.context_snapshot.v1", + "runId": "page-ai-run-abc", + "agentId": "reasonix", + "workspace": { + "sourceKind": "local_folder", + "rootUri": "file:///mnt/Data1T/mnote" + }, + "contextRefs": ["current_page", "active_editor"], + "primaryTarget": { + "documentId": "local-md:README.md", + "relativePath": "README.md", + "fileVersion": "mtime:size:hash" + }, + "availableReads": { + "currentPage": true, + "selection": false, + "folder": false, + "changedFiles": false + } +} +``` + +注意:snapshot 是工具返回,不是默认 prompt 注入。 + +## 7. AgentRunEnvelope 与 Receipt 的新位置 + +`AgentRunEnvelope` 不废弃,但职责调整: + +- 宿主侧构建,用于 audit、权限判定、工具输入和 receipt 关联。 +- 可以被 `mnote_context.snapshot` 精简返回。 +- 不默认进入 ACP `prompt_blocks`。 +- 不作为普通聊天请求的 system instructions。 + +`AgentRunReceipt` 仍保留: + +- run 完成后记录 changed files、touchesCurrentFile、shouldRefreshEditor、conflict。 +- clean buffer 下 agent 写入当前 `.md` 后触发事件驱动刷新。 +- dirty buffer 下进入 `external-change-conflict`。 +- 不通过轮询等待结果;使用 ACP events、watcher、WS/SSE 或现有浏览器事件。 + +## 8. 后端数据流 + +```text +Page AI composer + -> 用户选择 Agent / contextRefs + -> sendPageAiMessage() + -> payload: agentId + contextRefs + editorTarget + runTargetSnapshot metadata + -> 服务端认证 + SQLite grants 重算 + -> 构建 host-side AgentRunEnvelope + -> 判断是否挂载 MNote skill/tool capability + - chat_only: 不挂载 + - simple chat: 不挂载 + - Hermes/Reasonix + 文档任务: 挂载 skill summary + context tools + -> ACP run prompt_blocks 只包含用户原文 + -> agent 按需调用 mnote_skill.read / mnote_context.* / mnote_file.* + -> run completed + -> AgentRunReceipt / agentAudit.changedFiles + -> 事件驱动刷新当前页、文件树、页面树或显示 conflict +``` + +## 9. “是否挂载能力”的判定 + +第一阶段采用保守规则: + +暴露 MNote skill/tool capability: + +- agentId 是 `hermes` 或 `reasonix`。 +- 用户在 context popover 中勾选了 `current_page / selection / active_editor / file / folder / changed_files` 这类 MNote 上下文能力。 +- 用户 prompt 原文只用于 agent 自己推理,不由 MNote 侧按关键词分类成“普通聊天 / 文档任务”。 + +不挂载: + +- agentId 是 `chat_only`。 +- 用户没有勾选任何 MNote contextRef。 +- 当前没有有效 workspace / rootUri / session。 + +能力暴露不等于工具调用。MNote 只提供边界、授权和 skill/tool 描述;是否读取当前页、文件或附件由 agent 根据任务自行选择。 + +## 10. 与当前半成品实现的差异 + +当前半成品中已有一些方向需要修正: + +- `acp_stream_events` 已改为 prompt_blocks 只放用户原文,这是正确方向。 +- `should_skip_mnote_tool_context` 这类短句特判不应保留;应升级成只看 agentId / contextRefs / 授权边界的 capability attach policy。 +- `build_run_upstream_body(...).instructions` 仍可作为 legacy Hermes route 兼容,但不应进入 ACP prompt。 +- `toolGuidance` 不应变成长文本默认 instructions;应拆入 skill 正文,按需读取。 +- `AgentRunEnvelope` 应保留为 audit/tool input,不作为默认 prompt 文本。 + +## 11. 验收 Checklist + +### Batch A:冻结现状与 PilotDeck 对照 + +- [x] 记录当前“收到请回复收到”触发工具调用的真实浏览器证据。证据:用户截图/反馈记录了旧 UI 下短消息出现大量工具调用;本轮用 `task502-page-ai-agent-selector-context-smoke` 固化回归断言:短消息无 tool card,payload 不默认上传正文。 +- [x] 记录当前 ACP payload / prompt_blocks 是否包含 MNote instructions。证据:`acp_stream_events` 现只构造用户原文 `ContentBlock::Text`,不再调用 `build_run_upstream_body(...).instructions` 塞入 ACP prompt。 +- [x] 对照 PilotDeck `PromptAssembler.formatSkills()`,确认 MNote 只注入 skill 摘要。证据:参考 `reference-code/PilotDeck/src/context/prompt/PromptAssembler.ts`,MNote 新增 skill registry 摘要与 `mnote.skill.read` 懒加载。 +- [x] 对照 PilotDeck `InputProcessor`,确认 slash/command 不强行注入所有 command body。证据:参考 `reference-code/PilotDeck/src/context/input/InputProcessor.ts`,本轮未把 MNote skill 正文塞入普通用户 prompt。 + +### Batch B:Skill registry 设计与最小实现 + +- [x] 新增 MNote skill metadata registry。证据:`rust/crates/mnote-web/src/hermes_tools/skill.rs`。 +- [x] 新增 `mnote_skill.read` 或等价工具。证据:`mnote.skill.read` manifest + route dispatch。 +- [x] 添加 `mnote-current-page`、`mnote-local-file`、`mnote-chat-only` 三个最小 skill 正文。证据:`skills/mnote-*/SKILL.md`。 +- [x] 单测覆盖 skill id lookup、未知 skill、agentId 过滤。证据:`cargo test --manifest-path rust/Cargo.toml -p mnote-web skill_lookup -- --nocapture`。 + +### Batch C:Context tools 合同 + +- [x] 新增或收口 `mnote_context.snapshot`。证据:`mnote.context.snapshot` manifest + `context_tools::context_snapshot`。 +- [x] 新增或收口 `mnote_context.read_current_page`。证据:`mnote.context.read_current_page` manifest + current_page contextRef guard。 +- [x] 新增或收口 `mnote_context.resolve_target`。证据:`mnote.context.resolve_target` manifest + route dispatch。 +- [x] 工具返回结构化 JSON,不把正文混入 prompt。证据:context snapshot / resolve target 只返回 JSON;读取正文必须显式调用 read_current_page。 +- [x] Rust 定点测试覆盖 contextRefs 权限。证据:`cargo test --manifest-path rust/Cargo.toml -p mnote-web context_ref_enabled -- --nocapture`。 + +### Batch D:Capability attach policy + +- [x] 实现 `PageAiCapabilityPolicy` 或等价函数。证据:`page_ai_capability_policy`。 +- [x] `chat_only` 永不挂载 MNote 文件/页面工具。证据:policy 单测 + Reasonix wrapper chatLoop 使用空 ToolRegistry。 +- [x] MNote 不按“收到/你好/总结当前页”等 prompt 文本强行分类能力;普通消息是否调用工具由 agent 自己判断。证据:`page_ai_capability_policy_exposes_selected_context_without_prompt_classifying`、`capability_policy_does_not_text_classify_ack_prompt`。 +- [x] 只在 Hermes / Reasonix 且用户勾选 MNote contextRefs 时暴露 skill/tool capability;没有 contextRefs 或 Chat-only 时不挂载。证据:`page_ai_capability_policy_does_not_attach_without_context_refs`、`page_ai_capability_policy_never_attaches_for_chat_only`。 +- [x] 单测覆盖 selected contextRefs、无 contextRefs、Chat-only、ack prompt 不文本分类四类输入。证据:`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_capability_policy -- --nocapture`。 + +### Batch E:ACP prompt 收口 + +- [x] ACP `prompt_blocks` 只包含用户原文和用户显式附件,不包含 MNote tool guidance。证据:`acp_stream_events` 只传 `input.to_string()`。 +- [x] skill 摘要如果需要进入 system/context,只作为短 ``。证据:Reasonix wrapper mnoteLoop system prompt 仅保留短摘要,正文走 `mnote_skill_read`。 +- [x] `AgentRunEnvelope` 不进入 prompt_blocks。证据:ACP path 不再使用 upstream instructions。 +- [x] `build_run_upstream_body` 的 legacy instructions 不再被 ACP path 直接使用。证据:`acp_stream_events` 不调用 `build_run_upstream_body`。 + +### Batch F:UI 收口 + +- [x] Agent selector 改为 `+` 旁边的 Agent 按钮 + popover。证据:`task502-page-ai-agent-selector-context-smoke` 通过。 +- [x] Context refs 改为 `+` 旁边的上下文按钮 + popover。证据:`task502-page-ai-agent-selector-context-smoke` 通过。 +- [x] 输入区不显示 `write · /path` 黑色授权提示。证据:截图 `tmp/task502-page-ai-agent-selector-context-smoke/01-agent-selector-context.png`。 +- [x] 默认聊天面不显示 profile/model/gateway/runtime/tool trace。证据:`task502-page-ai-agent-selector-context-smoke` 断言通过。 +- [x] 浏览器截图验证 popover、选中态、输入区清爽度。证据:`tmp/task502-page-ai-agent-selector-context-smoke/01-agent-selector-context.png`。 + +### Batch G:Tool card 降噪 + +- [x] 普通聊天不显示 MNote tool card。证据:`task502-page-ai-agent-selector-context-smoke` 对“收到请回复收到”断言无 tool card。 +- [x] Page AI run payload 不默认上传 `pageText/pageXml/contextBlocks/selectedText/evidence` 正文;当前页/选区正文必须由 agent 通过 context tools 按需读取。证据:`pageAiPageContextForRefs()` 默认裁剪正文,`task502-page-ai-agent-selector-context-smoke` 断言通过。 +- [x] tool 调用失败时卡片默认折叠,显示简短错误和展开入口。证据:tool message 使用 `
`,默认不加 `open`。 +- [x] debug/raw trace 进入高级/调试区,不污染默认聊天。证据:trace/auditId 只在 tool `
` 内显示;默认聊天 smoke 截图无 runtime/raw trace。 +- [x] “收到请回复收到”真实浏览器测试只显示用户消息与 assistant 回复。证据:`task502-page-ai-agent-selector-context-smoke` 通过并生成截图。 + +### Batch H:Receipt 与事件驱动刷新 + +- [x] 保留 `AgentRunReceipt` / `agentAudit.changedFiles`。证据:`local_agent_audit_event` 写入 `agentRunReceipt`,`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_agent_audit_event_carries_agent_run_receipt -- --nocapture`。 +- [x] clean buffer agent 写入后事件驱动刷新当前页。证据:前端优先消费 `agentRunReceipt.refresh.touchesCurrentFile` 并派发 `mnote:page-ai-tool-write-completed`,`document-session-runtime.js` 负责 clean refresh / dirty conflict。 +- [x] dirty buffer agent 写入后进入 conflict。证据:`document-session-runtime.js` 的 `refreshDocumentSessionsFromExternalWrite` 对 dirty / saving / recent input 标记 `external-change-conflict`,事件来源改为 receipt。 +- [x] 不引入定时轮询。证据:本轮只使用 ACP SSE `run.completed`、浏览器 CustomEvent、现有 watcher/WS/SSE 事件,没有新增 `setInterval` 或轮询。 +- [x] changed files 与文件树/页面树刷新走 watcher、ACP event、WS/SSE 或现有事件。证据:`agentRunReceipt.changedFiles` 转为 `tree:local-folder-watch-batch`,由 `sidebar-tree-live-apply-runtime.js` 事件驱动刷新 affected parents。 + +### Batch I:验证矩阵与归档 + +- [x] Rust targeted tests:skill registry、capability policy、context tool auth。证据:`page_ai_capability_policy`、`skill_lookup`、`context_ref_enabled` 定点测试通过。 +- [x] JS `node --check`:Page AI runtime 与 smoke。证据:`node --check scripts/reasonix-acp-wrapper.mjs rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js rust/crates/mnote-web/browser/sidebar-tree-runtime.js scripts/task502-page-ai-agent-selector-context-smoke.js`。 +- [x] 浏览器 smoke:Agent popover、Context popover、纯聊天无工具调用、payload 不默认上传正文、receipt 事件驱动刷新。证据:`node scripts/task502-page-ai-agent-selector-context-smoke.js`。 +- [x] 浏览器截图:输入区不显示授权黑 chip。证据:`tmp/task502-page-ai-agent-selector-context-smoke/01-agent-selector-context.png`。 +- [x] `git diff --check`。 +- [x] `codegraph sync .`,必要时 `codegraph index . --force`。 +- [x] checklist 全部完成后移动到 `design/07-ai/done/`。 + +## 12. 归档条件 + +满足以下条件后,本稿可移动到 `design/07-ai/done/`: + +- Page AI 已采用 MNote skill library + context tools 模型。 +- ACP 用户 prompt 保持干净,不再默认注入 MNote instructions。 +- 纯聊天请求不会触发 MNote doc/page/file 工具。 +- 文档任务仍能通过 skill/tool 按需读取当前页或文件。 +- Agent / Context UI 已收敛为输入区按钮 + popover。 +- 真实浏览器截图与 smoke 证明默认聊天面没有冗余技术噪音。 diff --git a/design/07-ai/process/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md b/design/07-ai/process/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md new file mode 100644 index 00000000..79e93ed9 --- /dev/null +++ b/design/07-ai/process/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md @@ -0,0 +1,425 @@ +# 7-41 Page AI Hermes / Reasonix user profile isolation v2 + +> 创建时间:2026-05-29 +> +> 状态:`process` +> +> Owner:Page AI agent identity / Hermes profile policy / Reasonix memory policy +> +> 上位依据: +> - `design/07-ai/done/7-39-page-ai-agent-selector-context-authorization-settings-v1.md` +> - `design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md` +> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` +> - `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` +> +> 参考依据: +> - Hermes 官方 profile 文档:profile 是独立 Hermes home,包含 `config.yaml`、`.env`、`SOUL.md`、memories、sessions、skills、cron、state database、gateway state。 +> - Hermes WebUI:技能 toggle 直接写 profile `config.skills.disabled`。 +> - Hermes VSCode:只管理会话和一次性上下文建议,不提供真正的 per-user skill disable。 +> - PilotDeck:以 WorkSpace 为边界隔离文件、记忆和技能,并强调白盒记忆可追溯。 +> - Reasonix 文档/本地实现:默认使用 `~/.reasonix/memory/global` 与 `~/.reasonix/memory/`,`REASONIX_MEMORY=off|false|0` 可关闭 memory 注入。 + +## 1. 背景 + +`7-39` 已把 Page AI UI 收口为 agent selector + contextRefs + SQLite per-user preference。`7-40` 进一步把 MNote skill library 定位为 agent 可按需读取的能力摘要,而不是每轮强行注入 prompt。 + +但当前 Hermes skill/profile 的真实边界仍不正确: + +- Hermes 的 `skills.disabled` 是 profile 级配置,不是 MNote 用户级偏好。 +- Hermes profile 自带 `SOUL.md`、memory、session、skills 和 state;多个 MNote 用户共用同一个可写 profile,会把个人偏好、记忆和技能配置混在一起。 +- 当前 Page AI UI 已经能展示 Hermes skill toggle,但普通用户如果直接写共享 Hermes profile,就会影响其它用户。 +- Reasonix 虽然不像 Hermes 那样有 SOUL 人格,但它也有 global/project memory。默认共享 `~/.reasonix` 时,不能假设完全无状态。 + +因此本稿把 Page AI agent identity 从“选择 Hermes / Reasonix”提升为“选择 agent + profile scope + memory policy”。 + +## 2. 产品决策 + +### 2.1 Hermes profile 分层 + +Hermes profile 分为两类: + +| profile kind | owner | 普通用户能否使用 | 普通用户能否改 skill | memory/session | +| --- | --- | --- | --- | --- | +| `personal` | 单个 MNote 用户 | 能 | 能,仅限自己的 profile | 用户独立 | +| `shared` | 系统 / 管理员 | 能,若管理员公开 | 不能 | 默认不写入用户私有长期记忆 | + +初始共享 Hermes profile 只包含 `lite`。 + +要求: + +- 每个 MNote 用户可以访问自己的 personal Hermes profile。 +- 每个 MNote 用户可以访问被管理员公开的 shared Hermes profile。 +- shared Hermes profile 的 skill/config 只有管理员能改。 +- personal Hermes profile 的 skill/config 只有该 profile owner 或管理员能改。 +- Page AI 发送请求时必须携带解析后的 `agentProfileRef`,不能只携带裸 `profile=lite`。 + +### 2.2 MNote 内置技能开关 + +MNote 内置 skill / tool 属于 MNote 自己的能力面,普通用户应该可以按用户启停。该开关不写 Hermes profile,而是写 SQLite control-plane。 + +含义: + +- `mnote_builtin_skill_enabled` 是 per-user 偏好和服务端执行策略,必须按当前 SQLite 用户隔离。 +- `hide_builtin_skills=true` 只是 UI 展示偏好,用于折叠或隐藏 MNote 内置能力说明;它不等于禁用。 +- MNote tool 是否可调用由服务端根据 `user_id + agentId + contextRefs + allowedRoots + mnote_builtin_skill_enabled` 决定。 +- 普通用户可启停自己的 MNote 内置 skills;管理员可设置默认值或全局禁用策略。 +- MNote 内置 skill 开关不修改 Hermes profile `config.yaml`,也不影响 shared Hermes profile。 + +### 2.3 Hermes profile skills + +可以配置的只有 Hermes profile 自己的 skills。 + +规则: + +- personal profile:用户可启停该 profile 下的 Hermes skills,写入该 personal profile 的 `config.yaml`。 +- shared profile:普通用户只读;管理员可启停 shared profile skills。 +- skill toggle API 必须做服务端权限检查,不能只靠 UI 禁用按钮。 +- MNote 不再把 `ai.agent.hermes.profile..skills.enabled` 当成普通用户对共享 profile 的安全开关;MNote 内置 skill 的 per-user 开关应使用独立 SQLite key。 + +### 2.4 Reasonix memory policy + +Reasonix 默认关闭 memory 注入。 + +规则: + +- 默认启动 Reasonix ACP 时设置 `REASONIX_MEMORY=off`。 +- 每个 MNote 用户可在设置中开启 Reasonix memory。 +- 该设置保存到 SQLite `user_ui_preferences`,按用户隔离。 +- 开启后,UI 需明确标注 Reasonix 将使用 global/project memory。 +- 第一阶段不强制给 Reasonix 每用户独立 HOME;若后续需要更强隔离,再增加 `reasonix_home_mode=per_user`。 + +## 3. 数据合同 + +### 3.1 Agent profile ref + +Page AI payload 中新增或固定以下结构: + +```json +{ + "agentId": "hermes", + "agentProfileRef": { + "kind": "personal", + "profileId": "usr_123_default", + "ownerUserId": "usr_123", + "baseProfile": "default", + "displayName": "我的 Hermes" + } +} +``` + +shared profile: + +```json +{ + "agentId": "hermes", + "agentProfileRef": { + "kind": "shared", + "profileId": "shared_lite", + "ownerUserId": null, + "baseProfile": "lite", + "displayName": "Lite" + } +} +``` + +服务端要求: + +- 不接受浏览器直接提交任意 Hermes filesystem path。 +- `profileId` 必须由 SQLite control-plane 解析到当前用户可访问的 profile。 +- 对 personal profile,当前用户必须是 owner 或 admin。 +- 对 shared profile,必须是公开 profile 或 admin。 +- 所有 run / skill list / skill toggle / settings read 都通过 `agentProfileRef` 解析,不走裸 profile name。 + +### 3.2 SQLite control-plane + +建议新增控制面表,避免只用偏好 key 表达权限关系: + +```text +ai_agent_profiles +- id +- agent_id # hermes +- profile_kind # personal | shared +- owner_user_id # personal 必填,shared 为空 +- base_profile_name # Hermes 原始 profile 名或模板名 +- isolated_profile_name # MNote 管理后的真实 Hermes profile 名 +- display_name +- status # active | disabled +- created_at +- updated_at + +ai_agent_profile_grants +- profile_id +- user_id +- role # owner | user | admin +- can_run +- can_manage_skills +- can_manage_config +- created_at +- updated_at +``` + +第一阶段也可以在现有 `user_ui_preferences` 中保存默认选择: + +- `ai.agent.hermes.default_profile_id` +- `ai.agent.hermes.hide_builtin_skills` +- `ai.agent.mnote_builtin.skills.enabled` +- `ai.agent.reasonix.memory_enabled` + +但 profile 权限、owner、shared/personal 类型不应只存在于 UI preference。 + +MNote 内置 skill 的用户级开关可以第一阶段存在 `user_ui_preferences`,但服务端 tool policy 读取时必须视为执行策略,而不是纯 UI 状态。若后续需要审计、管理员默认值或组织策略,应升级为独立表: + +```text +ai_user_skill_preferences +- user_id +- skill_id +- enabled +- updated_at +``` + +### 3.3 Hermes profile provisioning + +创建 personal Hermes profile 时: + +- 可从共享模板复制 `config.yaml`、`.env`、`SOUL.md` 和 skills。 +- 不复制 memories、sessions、state database、gateway state。 +- 生成的真实 profile name 必须包含 MNote 用户隔离标识,例如 `mnote-u--default`。 +- provisioning 过程由服务端执行,并记录到 SQLite control-plane。 + +shared `lite`: + +- 初始由管理员登记为 `shared_lite`。 +- 普通用户只可 run / list readonly。 +- 管理员可改 skill/config。 + +## 4. API 合同 + +### 4.1 Profile list + +`GET /api/ai/agent-profiles?agentId=hermes` + +返回当前用户可访问 profiles: + +```json +{ + "profiles": [ + { + "profileId": "usr_123_default", + "kind": "personal", + "displayName": "我的 Hermes", + "canRun": true, + "canManageSkills": true + }, + { + "profileId": "shared_lite", + "kind": "shared", + "displayName": "Lite", + "canRun": true, + "canManageSkills": false + } + ] +} +``` + +### 4.2 Skill list + +`GET /api/hermes/client/skills?profileId=...` + +要求: + +- personal profile 返回可 toggle 状态。 +- shared profile 对普通用户返回 readonly 状态。 +- MNote 内置 skills 返回值必须标记 `builtin=true`、`configurable=true`、`configScope=user_sqlite`。 +- Hermes profile skills 标记 `builtin=false`、`configurable=canManageSkills`。 + +### 4.3 Skill toggle + +`PUT /api/hermes/client/skills/toggle` + +请求: + +```json +{ + "profileId": "usr_123_default", + "skillName": "writer", + "enabled": false +} +``` + +服务端必须: + +- 解析 `profileId`。 +- 若 `skillKind=mnote_builtin`,写当前用户 SQLite skill preference,不写 Hermes profile。 +- 若 `skillKind=hermes_profile`,校验 `canManageSkills=true`。 +- 拒绝普通用户修改 shared profile 的 Hermes profile skills。 +- personal profile skill toggle 只写目标 Hermes profile 的 `config.yaml`。 + +错误码建议: + +- `ai_profile_not_found` +- `ai_profile_forbidden` +- `ai_profile_readonly` +- `ai_builtin_skill_preference_failed` +- `hermes_skill_toggle_failed` + +### 4.4 Reasonix run + +Reasonix ACP spawn / session create 需读取当前用户设置: + +```json +{ + "agentId": "reasonix", + "memoryPolicy": { + "enabled": false, + "source": "user_ui_preferences" + } +} +``` + +默认: + +- `enabled=false` +- 子进程环境包含 `REASONIX_MEMORY=off` + +开启: + +- 不设置 `REASONIX_MEMORY=off`,或设置为 `on` +- UI 明确展示 memory 已开启 + +## 5. UI 设计 + +### 5.1 Agent selector + +Hermes agent 下增加 profile 子选择: + +```text +Hermes + 我的 Hermes personal · 可配置 + Lite shared · 只读 +``` + +显示规则: + +- personal profile 显示“可配置”。 +- shared profile 显示“共享 / 只读”。 +- 若普通用户选择 shared profile,技能开关显示为只读。 +- 管理员选择 shared profile,技能开关可用,并显示“管理员正在修改共享 profile”。 + +### 5.2 Skills panel + +三组仍保留: + +- MNote 内置技能:可折叠,可隐藏/显示,也可由普通用户按自己账号启停。 +- Hermes 技能:随当前 Hermes profile 变化;personal 可配置,shared 普通用户只读。 +- Reasonix 技能:展示可用能力;memory 是单独设置,不混入 skill toggle。 + +Hermes profile 切换时: + +- 必须重新加载 profile skills。 +- 必须清空旧 profile skill cache。 +- `hide_builtin_skills` 不随 Hermes profile 改变;它是用户 UI 偏好。 +- MNote 内置 skill enable/disable 不随 Hermes profile 改变;它是当前 MNote 用户的 SQLite policy。 + +### 5.3 Settings + +设置页拆分: + +- Common:授权区域、contextRefs 默认值、内置技能显示/隐藏、内置技能启停。 +- Hermes:默认 Hermes profile、personal profile 管理、shared profile 只读/管理员管理。 +- Reasonix:默认关闭 memory;用户可开启。 +- Chat-only:只聊天配置。 + +## 6. 非目标 + +- 不让普通用户直接修改 shared Hermes profile。 +- 不把 shared Hermes profile 用作沉淀个人偏好的长期人格。 +- 不在本阶段实现 Reasonix per-user HOME;只实现默认 memory off 与可选开启。 +- 不新增第二套目录授权真相;文件访问仍由 SQLite directory grants / allowedRoots 控制。 +- 不实现 PilotDeck 的完整 router、always-on 或 memory engine。 + +## 7. Checklist + +### Batch A - 现状冻结与风险取证 + +- [ ] 复核当前 Page AI Hermes skill toggle 的真实写入路径,确认是否直接写 Hermes profile `config.yaml`。 +- [ ] 复核当前 UI preference 中 `hide_builtin`、MNote 内置 skill enabled、profile skill enabled、default profile 的存储键。 +- [ ] 复核 Reasonix ACP spawn 环境,确认当前是否默认注入 memory。 +- [ ] 形成 RED 证据:普通用户修改 shared profile skill 会影响其它用户,或当前缺少服务端权限边界。 +- [ ] 验证:Rust/JS 只读审计记录在本文档或后续 checklist evidence 中。 + +### Batch B - SQLite profile policy 合同 + +- [ ] 新增或扩展 SQLite control-plane profile policy:`ai_agent_profiles` / `ai_agent_profile_grants` 或等价结构。 +- [ ] 初始化 shared Hermes profile:仅 `lite`,普通用户 `canRun=true`、`canManageSkills=false`。 +- [ ] 为每个用户 provision personal Hermes profile。 +- [ ] 补 Rust 定点测试:personal owner、shared readonly、admin manage、跨用户不可管理。 +- [ ] 验证:不同用户查询 profile list 只返回自己 personal + shared lite。 + +### Batch C - Hermes profile resolver + +- [ ] 新增服务端 `agentProfileRef` resolver,禁止前端提交任意 Hermes path。 +- [ ] `/api/hermes/client/runs` 从 `profileId` 解析真实 Hermes profile。 +- [ ] `/api/hermes/client/skills` 从 `profileId` 解析真实 Hermes profile。 +- [ ] 保留旧 `profile=` 参数只作为兼容入口,并映射到当前用户可访问 profile。 +- [ ] 验证:旧路径兼容不允许越权访问 shared/admin profile。 + +### Batch D - Skill toggle 权限收口 + +- [ ] 修改 skill toggle API:只接受 `profileId + skillName + enabled`。 +- [ ] 区分 `mnote_builtin` 与 `hermes_profile` skill kind。 +- [ ] MNote 内置 skill toggle 写当前用户 SQLite preference。 +- [ ] 拒绝普通用户修改 shared profile 的 Hermes profile skills。 +- [ ] personal profile skill toggle 只写该用户 isolated profile `config.yaml`。 +- [ ] shared profile skill toggle 仅 admin 可写。 +- [ ] 验证:Rust API 测试覆盖 `ai_profile_readonly`、MNote 内置 skill per-user toggle、personal profile skill success。 + +### Batch E - MNote 内置 skill per-user policy + +- [ ] 将 MNote 内置 skill enable/disable 保存为 SQLite per-user policy。 +- [ ] UI 中 `hide_builtin_skills` 只影响展示,不影响 enable/disable。 +- [ ] Skills panel 标记 `builtin=true`、`configurable=true`、`configScope=user_sqlite`。 +- [ ] 服务端 MNote tool policy 按 `user_id + agentId + contextRefs + allowedRoots + mnote_builtin_skill_enabled` 判断。 +- [ ] 验证:用户 A 禁用某内置 skill 不影响用户 B;禁用后对应 MNote tool 被服务端拒绝;隐藏展示不影响 enable 状态。 + +### Batch F - Reasonix memory policy + +- [ ] 新增 per-user 设置 `ai.agent.reasonix.memory_enabled`,默认 `false`。 +- [ ] Reasonix ACP spawn 默认设置 `REASONIX_MEMORY=off`。 +- [ ] 开启 memory 后不注入 `REASONIX_MEMORY=off`,并在 UI 显示 memory enabled。 +- [ ] 补 JS/Rust 测试或 smoke,覆盖默认 off、用户开启、不同用户隔离 preference。 +- [ ] 验证:普通消息由 Reasonix 自己决定是否使用工具;MNote 不再强行注入 memory/context 正文。 + +### Batch G - UI 收口 + +- [ ] Agent selector 中 Hermes profile 显示 personal/shared/readonly 状态。 +- [ ] Skills panel 三组均可折叠。 +- [ ] Hermes profile 切换必须刷新 skill catalog,避免旧 profile skill 残留。 +- [ ] shared profile 的 Hermes profile skills 对普通用户展示只读开关或锁定状态。 +- [ ] MNote 内置 skills 对普通用户展示可启停状态,并标明按当前 MNote 用户保存。 +- [ ] 管理员对 shared profile 显示可管理状态,并提示影响所有用户。 +- [ ] 验证:真实浏览器截图覆盖 MNote 内置 skill per-user 可配置、personal Hermes skill 可配置、shared Hermes skill 只读、admin shared 可配置。 + +### Batch H - 回归矩阵与文档收尾 + +- [ ] 更新 Page AI 设计说明,明确 MNote 内置 skill per-user policy、personal/shared Hermes profile 与 Reasonix memory policy。 +- [ ] 更新 smoke:agent 切换、MNote 内置 skill per-user toggle、Hermes profile 切换、shared readonly、personal skill toggle、Reasonix memory off/on。 +- [ ] 运行 `node --check` 覆盖相关 browser runtime / smoke。 +- [ ] 运行 Rust 定点测试覆盖 SQLite profile policy 与 Hermes skill toggle 权限。 +- [ ] 运行真实浏览器验证并截图。 +- [ ] 运行 `git diff --check`。 +- [ ] 涉及代码图后运行 `codegraph sync .`。 +- [ ] 完成后将本 checklist 移动到 `done/` 或标记为 `done`。 + +## 8. 验收口径 + +完成后必须满足: + +- 普通用户可以启停自己的 MNote 内置 skills,开关保存到 SQLite 并由服务端 tool policy 执行。 +- 用户 A 的 MNote 内置 skill 开关不影响用户 B。 +- 普通用户无法修改 shared `lite` 的 Hermes profile skills。 +- 普通用户可以修改自己的 personal Hermes profile skills。 +- MNote 内置 skill 的显示/隐藏与启停是两个不同状态:隐藏只影响 UI,启停影响服务端可调用性。 +- Hermes profile 切换后 skill catalog 正确刷新。 +- Reasonix 默认 memory off,开启 memory 是 per-user preference。 +- Page AI run payload 不再用裸 Hermes profile name 表达身份,而是经服务端解析的 `agentProfileRef` / `profileId`。 +- 所有文件访问仍受 SQLite directory grants / allowedRoots 约束。 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 118f6f09..f673ddf7 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -320,9 +320,11 @@ export function createSidebarPageAiRuntime(context) { var record = pageAiAgentRecord(next); pageUiState.pageAiAgentId = next; pageUiState.pageAiAcpRuntime = record.acpRuntime || 'reasonix'; + pageUiState.pageAiAgentPopoverOpen = next === 'hermes'; if (next === 'hermes') pageAiSetActiveProfile(pageAiCurrentProfile()); document.documentElement.setAttribute('data-mnote-page-ai-agent-id', next); pageAiPersistAiPreference('default_agent_id', next); + void pageAiLoadSkills(); renderPageAiProviderButtons(); renderPageAiControls(); } @@ -337,7 +339,62 @@ export function createSidebarPageAiRuntime(context) { renderPageAiControls(); } + function pageAiSetContextPopoverOpen(open) { + pageUiState.pageAiContextPopoverOpen = Boolean(open); + if (open) pageUiState.pageAiAgentPopoverOpen = false; + renderPageAiControls(); + } + + function pageAiSetAgentPopoverOpen(open) { + pageUiState.pageAiAgentPopoverOpen = Boolean(open); + if (open) pageUiState.pageAiContextPopoverOpen = false; + renderPageAiControls(); + } + + 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 pageAiPersistAiPreference(key, value) { + pageAiPersistRawAiPreference('ai.common.' + key, value); + } + + function pageAiPersistRawAiPreference(key, value) { try { var workspaceId = resolveWorkspaceId(document.body); var body = { @@ -347,7 +404,7 @@ export function createSidebarPageAiRuntime(context) { documentId: currentDocumentId(), updates: {} }; - body.updates['ai.common.' + key] = value; + body.updates[key] = value; void fetch('/api/ui/preferences', { method: 'PUT', headers: { 'content-type': 'application/json', accept: 'application/json' }, @@ -369,16 +426,23 @@ export function createSidebarPageAiRuntime(context) { 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'; } + var hermesProfile = String(preferences['ai.agent.hermes.profile_id'] || '').trim(); + if (hermesProfile) pageAiSetActiveProfile(hermesProfile); 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(); } @@ -531,22 +595,18 @@ export function createSidebarPageAiRuntime(context) { function pageAiPageContextForRefs(pageContext, contextRefs) { var cloned = pageAiCloneJson(pageContext) || {}; - var kinds = pageAiContextKindsFromRefs(contextRefs); var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {}; - if (!kinds.current_page) { - delete cloned.documentBlocks; - 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; - } + delete cloned.documentBlocks; + delete cloned.evidence; + delete aiContext.contextBlocks; + delete aiContext.pageText; + delete aiContext.pageXml; + delete aiContext.truncated; + delete aiContext.warnings; + delete aiContext.selectedText; + delete aiContext.selectedBlockIds; + delete aiContext.selectedBlocks; + delete aiContext.allowedTargetBlockIds; cloned.aiContext = aiContext; return cloned; } @@ -754,7 +814,7 @@ export function createSidebarPageAiRuntime(context) { pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope, evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null, pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null, - contentAccess: 'mnote.doc.fetch', + contentAccess: 'mnote.context.read_current_page', aiContext: aiContext }, editorTarget: editorTarget, @@ -1532,13 +1592,17 @@ export function createSidebarPageAiRuntime(context) { skills: pageAiNormalizeArray(category && category.skills).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) + modified: Boolean(skill && skill.modified), + category: String(category && category.name || '').trim() }; }) }; @@ -1546,8 +1610,11 @@ export function createSidebarPageAiRuntime(context) { 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(), @@ -1565,8 +1632,11 @@ export function createSidebarPageAiRuntime(context) { 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 || '', @@ -1666,6 +1736,74 @@ export function createSidebarPageAiRuntime(context) { }).filter(Boolean).join('\n'); } + 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) { + document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true'); + window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', { + detail: { + payload: { + schema: 'mnote.local_folder.watch_batch.v1', + source: 'agent_run_receipt', + runId: runId, + rootUri: String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || ''), + changedPaths: changedPaths, + affectedParents: affectedParents + } + } + })); + } + } + if (refresh.touchesCurrentFile === true) { + 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) { @@ -1992,9 +2130,11 @@ export function createSidebarPageAiRuntime(context) { function pageAiFilteredSkillEntries() { var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase(); - return pageAiSkillListEntries().filter(function(skill) { + return pageAiAllSkillEntries().filter(function(skill) { + if (skill.group === 'hermes' && pageAiHideHermesBuiltinSkills(pageAiCurrentProfile()) && skill.builtin === true) return false; if (!query) return true; - return String(skill.name || '').toLowerCase().indexOf(query) >= 0 + 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; @@ -2018,6 +2158,162 @@ export function createSidebarPageAiRuntime(context) { return '本地'; } + function pageAiSkillPreferenceKey(group, profile) { + var groupName = String(group || '').trim(); + if (groupName === 'mnote') return 'ai.common.skills.mnote.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) { + var nextProfile = String(profile || pageAiCurrentProfile() || 'default').trim() || 'default'; + return 'ai.agent.hermes.profile.' + nextProfile + '.skills.hide_builtin'; + } + + function pageAiHideHermesBuiltinSkills(profile) { + var preferences = pageUiState.pageAiSkillPreferences || {}; + return preferences[pageAiHermesHideBuiltinPreferenceKey(profile)] === true; + } + + 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 catalog = 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, + id: id, + name: skill.name || id, + title: skill.title || skill.name || id, + description: skill.description || '', + enabled: group === 'hermes' ? skill.enabled !== false : overrides[id] !== false, + toggleable: skill.toggleable !== false, + source: skill.source || group, + origin: skill.origin || '', + readOnly: skill.readOnly === true, + builtin: pageAiSkillIsBuiltin(skill), + toolNames: skill.toolNames || [], + 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', + id: id, + name: skill.name || id, + title: skill.title || skill.name || id, + description: skill.description || '', + enabled: group === 'hermes' ? skill.enabled !== false : overrides[id] !== false, + toggleable: skill.toggleable !== false, + source: skill.source || group, + origin: skill.origin || '', + readOnly: skill.readOnly === true, + builtin: pageAiSkillIsBuiltin(skill), + toolNames: skill.toolNames || [], + 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; + } + + function pageAiLoadSkillCatalog(runtime, profile) { + var params = new URLSearchParams(); + params.set('runtime', runtime); + if (runtime === 'mnote') { + params.set('agentId', pageUiState.pageAiAgentId || 'reasonix'); + } else if (runtime === 'reasonix') { + params.set('runtime', 'reasonix'); + } else if (runtime === 'hermes' && profile) { + params.set('profile', profile); + } + return fetch('/api/hermes/client/skills?' + 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, 'skills_failed_' + response.status)); + return pageAiNormalizeSkills(payload); + }); + }); + } + async function pageAiLoadProfiles() { try { var response = await fetch('/api/hermes/client/profiles', { @@ -2050,6 +2346,12 @@ export function createSidebarPageAiRuntime(context) { var next = String(profileName || '').trim(); if (!next) return; pageAiSetActiveProfile(next); + pageAiPersistRawAiPreference('ai.agent.hermes.profile_id', next); + pageUiState.pageAiSkills = { categories: [], archived: [] }; + pageUiState.pageAiSkillCatalogs = Object.assign({}, pageUiState.pageAiSkillCatalogs || {}, { + hermes: { categories: [], archived: [] } + }); + pageUiState.pageAiSkillError = ''; renderPageAiProviderButtons(); renderPageAiControls(); try { @@ -2121,59 +2423,81 @@ export function createSidebarPageAiRuntime(context) { } async function pageAiLoadSkills() { + var requestedProfile = pageAiCurrentProfile(); + var loadSeq = (Number(pageUiState.pageAiSkillLoadSeq || 0) || 0) + 1; + pageUiState.pageAiSkillLoadSeq = loadSeq; try { - var runtime = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim(); - var params = runtime === 'reasonix' - ? 'runtime=reasonix' - : 'profile=' + encodeURIComponent(pageAiCurrentProfile()); - var response = await fetch('/api/hermes/client/skills?' + params, { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skills_failed_' + response.status)); - pageUiState.pageAiSkills = pageAiNormalizeSkills(payload); + var catalogs = await Promise.all([ + pageAiLoadSkillCatalog('mnote', ''), + pageAiLoadSkillCatalog('reasonix', ''), + pageAiLoadSkillCatalog('hermes', requestedProfile) + ]); + if (pageUiState.pageAiSkillLoadSeq !== loadSeq || pageAiCurrentProfile() !== requestedProfile) return; + pageUiState.pageAiSkillCatalogs = { + mnote: catalogs[0], + reasonix: catalogs[1], + hermes: catalogs[2] + }; + pageUiState.pageAiSkills = catalogs[2]; pageUiState.pageAiSkillError = ''; } catch (error) { + if (pageUiState.pageAiSkillLoadSeq !== loadSeq || pageAiCurrentProfile() !== requestedProfile) 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) { + async function pageAiToggleSkill(skillName, enabled, group, profile) { var name = String(skillName || '').trim(); if (!name) return; - if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return; + var skillGroup = String(group || 'hermes').trim() || 'hermes'; + var skillProfile = String(profile || pageAiCurrentProfile() || '').trim(); + if (skillGroup === 'mnote' || skillGroup === 'reasonix') { + pageAiSetSkillPreference(skillGroup, name, Boolean(enabled), skillProfile); + document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', skillGroup + ':' + name); + renderPageAiControls(); + return; + } var previous = null; - pageAiSkillListEntries().forEach(function(skill) { - if (skill.name === name && previous == null) previous = skill.enabled !== false; + pageAiToggleableSkillEntries('hermes', 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: pageAiCurrentProfile(), + profile: skillProfile || pageAiCurrentProfile(), 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)); - pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { + 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.enabled = Boolean(enabled); + 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) { - pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { + 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.enabled = previous; + if (skill.name === name || skill.id === name) skill.enabled = previous; }); }); } @@ -2240,25 +2564,85 @@ export function createSidebarPageAiRuntime(context) { var drawer = ensurePageAiDrawer(); var isAcp = pageUiState.pageAiAcpRuntime !== ''; var activeAgentId = pageAiCurrentAgentId(); - var activeProfile = pageAiRunProfile(); + var activeProfile = pageAiCurrentProfile(); 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'); document.documentElement.setAttribute('data-mnote-page-ai-agent-id', activeAgentId); - var agentSelector = drawer.querySelector('[data-page-ai-agent-selector]'); - if (agentSelector instanceof HTMLElement) { - agentSelector.innerHTML = PAGE_AI_AGENT_REGISTRY.map(function(agent) { - var active = agent.id === activeAgentId; - return ''; - }).join(''); + var agentButton = drawer.querySelector('[data-page-ai-agent-button]'); + var agentPopoverId = 'mnote-page-ai-agent-popover'; + if (agentButton instanceof HTMLElement) { + var activeAgent = pageAiAgentRecord(activeAgentId); + agentButton.textContent = 'AI'; + agentButton.setAttribute('aria-expanded', pageUiState.pageAiAgentPopoverOpen ? 'true' : 'false'); + agentButton.setAttribute('aria-controls', agentPopoverId); + agentButton.setAttribute('data-page-ai-agent-summary', activeAgent.label); + agentButton.setAttribute('aria-label', 'Agent:' + activeAgent.label); + agentButton.setAttribute('title', 'Agent:' + activeAgent.label); } - var contextRefs = drawer.querySelector('[data-page-ai-context-refs]'); - if (contextRefs instanceof HTMLElement) { + var agentPopover = drawer.querySelector('[data-page-ai-agent-popover]'); + if (agentPopover instanceof HTMLElement) { + agentPopover.id = agentPopoverId; + agentPopover.hidden = !pageUiState.pageAiAgentPopoverOpen; + agentPopover.innerHTML = '' + + '
' + + '选择 Agent' + + '' + + '
' + + '
' + + PAGE_AI_AGENT_REGISTRY.map(function(agent) { + var active = agent.id === activeAgentId; + var detail = agent.canWriteFiles ? '可读取并在授权目录内写文件' : '只聊天,不申请文件写权限'; + return '' + + ''; + }).join('') + + '
' + + (activeAgentId === 'hermes' + ? '' + : ''); + } + 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(); - contextRefs.innerHTML = PAGE_AI_CONTEXT_REF_REGISTRY.map(function(ref) { - var active = selectedRefs[ref.id] !== false && selectedRefs[ref.id] === true; - return ''; - }).join(''); + 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 allowedRootsNode = drawer.querySelector('[data-page-ai-allowed-roots]'); if (allowedRootsNode instanceof HTMLElement) { @@ -2424,29 +2808,40 @@ export function createSidebarPageAiRuntime(context) { if (skillList instanceof HTMLElement) { var skills = pageAiFilteredSkillEntries(); if (!skills.length) { - var emptyText = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix' - ? '没有匹配的 Reasonix skill。' - : '没有匹配的 Hermes skill。'; - skillList.innerHTML = '
' + escapeHtml(emptyText) + '
'; + skillList.innerHTML = '
没有匹配的技能。
'; } else { - skillList.innerHTML = skills.map(function(skill) { - var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : ''); - var description = String(skill.description || '').trim(); - var hasDescription = description && description !== '---' && description !== '无描述'; - var canToggle = skill.toggleable !== false && String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() !== 'reasonix'; + var groupLabels = { mnote: 'MNote 内置技能', reasonix: 'Reasonix 技能', hermes: 'Hermes 技能' }; + skillList.innerHTML = ['mnote', 'reasonix', 'hermes'].map(function(group) { + var groupSkills = skills.filter(function(skill) { return skill.group === group; }); + if (!groupSkills.length) return ''; + var headingExtra = group === 'hermes' ? ' · ' + pageAiCurrentProfile() : ''; + var collapsed = pageAiSkillGroupCollapsed(group); return '' + - '
' + - '
' + - '
' + - '
' + escapeHtml(skill.name) + '
' + - '
' + escapeHtml(sourceText) + '
' + - '
' + - (hasDescription ? '
' + escapeHtml(description) + '
' : '') + - '
' + - '' + - '
'; + (collapsed ? '' : groupSkills.map(function(skill) { + var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : ''); + var description = String(skill.description || '').trim(); + var hasDescription = description && description !== '---' && description !== '无描述'; + var displayName = String(skill.title || skill.name || skill.id || '').trim(); + return '' + + '
' + + '
' + + '
' + + '
' + escapeHtml(displayName) + '
' + + '
' + escapeHtml(sourceText) + '
' + + '
' + + (hasDescription ? '
' + escapeHtml(description) + '
' : '') + + '
' + + '' + + '
'; + }).join('')) + + ''; }).join(''); } } @@ -2574,6 +2969,14 @@ export function createSidebarPageAiRuntime(context) { '' + '' + '
' + + '' + + '' + '' + '' + '
' + @@ -2581,9 +2984,9 @@ export function createSidebarPageAiRuntime(context) { '
' + '
' + '
' + - '' + + '' + '当前页' + '空闲' + '
' + @@ -2681,6 +3084,14 @@ export function createSidebarPageAiRuntime(context) { '搜索技能' + '' + '' + + '' + + '' + '
' + '' + '
' + @@ -2742,13 +3153,17 @@ export function createSidebarPageAiRuntime(context) { '' + '