feat(page-ai): add skill context and agent profile policy

This commit is contained in:
lix-2026
2026-05-29 21:57:29 +08:00
parent 631205ba2f
commit 49a0545148
18 changed files with 3478 additions and 259 deletions
@@ -0,0 +1,456 @@
# 7-40 Page AI MNote skill library and context tool contract v1
> 创建时间:2026-05-29
>
> 状态:`PROCESS`
>
> OwnerPage 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()` 只生成 `<available-skills>` 摘要,并明确提示 `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 也是单独 `<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 toolsHermes / 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
<available-skills>
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.
</available-skills>
```
禁止:
- 把每个 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 BSkill 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 CContext 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 DCapability 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 EACP prompt 收口
- [x] ACP `prompt_blocks` 只包含用户原文和用户显式附件,不包含 MNote tool guidance。证据:`acp_stream_events` 只传 `input.to_string()`
- [x] skill 摘要如果需要进入 system/context,只作为短 `<available-skills>`。证据: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 FUI 收口
- [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 GTool 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 使用 `<details class="wolai-page-ai-tool-details">`,默认不加 `open`
- [x] debug/raw trace 进入高级/调试区,不污染默认聊天。证据:trace/auditId 只在 tool `<details>` 内显示;默认聊天 smoke 截图无 runtime/raw trace。
- [x] “收到请回复收到”真实浏览器测试只显示用户消息与 assistant 回复。证据:`task502-page-ai-agent-selector-context-smoke` 通过并生成截图。
### Batch HReceipt 与事件驱动刷新
- [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 testsskill 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] 浏览器 smokeAgent 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 证明默认聊天面没有冗余技术噪音。
@@ -0,0 +1,425 @@
# 7-41 Page AI Hermes / Reasonix user profile isolation v2
> 创建时间:2026-05-29
>
> 状态:`process`
>
> OwnerPage 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/<project-hash>``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.<name>.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-<userId>-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。
- [ ] 更新 smokeagent 切换、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 约束。
@@ -320,9 +320,11 @@ export function createSidebarPageAiRuntime(context) {
var record = pageAiAgentRecord(next); var record = pageAiAgentRecord(next);
pageUiState.pageAiAgentId = next; pageUiState.pageAiAgentId = next;
pageUiState.pageAiAcpRuntime = record.acpRuntime || 'reasonix'; pageUiState.pageAiAcpRuntime = record.acpRuntime || 'reasonix';
pageUiState.pageAiAgentPopoverOpen = next === 'hermes';
if (next === 'hermes') pageAiSetActiveProfile(pageAiCurrentProfile()); if (next === 'hermes') pageAiSetActiveProfile(pageAiCurrentProfile());
document.documentElement.setAttribute('data-mnote-page-ai-agent-id', next); document.documentElement.setAttribute('data-mnote-page-ai-agent-id', next);
pageAiPersistAiPreference('default_agent_id', next); pageAiPersistAiPreference('default_agent_id', next);
void pageAiLoadSkills();
renderPageAiProviderButtons(); renderPageAiProviderButtons();
renderPageAiControls(); renderPageAiControls();
} }
@@ -337,7 +339,62 @@ export function createSidebarPageAiRuntime(context) {
renderPageAiControls(); 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) { function pageAiPersistAiPreference(key, value) {
pageAiPersistRawAiPreference('ai.common.' + key, value);
}
function pageAiPersistRawAiPreference(key, value) {
try { try {
var workspaceId = resolveWorkspaceId(document.body); var workspaceId = resolveWorkspaceId(document.body);
var body = { var body = {
@@ -347,7 +404,7 @@ export function createSidebarPageAiRuntime(context) {
documentId: currentDocumentId(), documentId: currentDocumentId(),
updates: {} updates: {}
}; };
body.updates['ai.common.' + key] = value; body.updates[key] = value;
void fetch('/api/ui/preferences', { void fetch('/api/ui/preferences', {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json', accept: 'application/json' }, 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' var preferences = payload.result.aiPreferences && typeof payload.result.aiPreferences === 'object'
? payload.result.aiPreferences ? payload.result.aiPreferences
: {}; : {};
pageUiState.pageAiSkillPreferences = Object.assign({}, preferences);
var defaultAgent = String(preferences['ai.common.default_agent_id'] || '').trim(); var defaultAgent = String(preferences['ai.common.default_agent_id'] || '').trim();
if (defaultAgent) { if (defaultAgent) {
var agent = pageAiAgentRecord(defaultAgent); var agent = pageAiAgentRecord(defaultAgent);
pageUiState.pageAiAgentId = agent.id; pageUiState.pageAiAgentId = agent.id;
pageUiState.pageAiAcpRuntime = agent.acpRuntime || pageUiState.pageAiAcpRuntime || 'reasonix'; 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']; var selected = preferences['ai.common.context_refs.default_selected'];
if (selected && typeof selected === 'object' && !Array.isArray(selected)) { if (selected && typeof selected === 'object' && !Array.isArray(selected)) {
pageUiState.pageAiSelectedContextRefs = Object.assign({}, pageAiEnsureContextRefState(), 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 (_) {} } catch (_) {}
renderPageAiControls(); renderPageAiControls();
} }
@@ -531,22 +595,18 @@ export function createSidebarPageAiRuntime(context) {
function pageAiPageContextForRefs(pageContext, contextRefs) { function pageAiPageContextForRefs(pageContext, contextRefs) {
var cloned = pageAiCloneJson(pageContext) || {}; var cloned = pageAiCloneJson(pageContext) || {};
var kinds = pageAiContextKindsFromRefs(contextRefs);
var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {}; var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {};
if (!kinds.current_page) {
delete cloned.documentBlocks; delete cloned.documentBlocks;
delete cloned.evidence;
delete aiContext.contextBlocks; delete aiContext.contextBlocks;
delete aiContext.pageText; delete aiContext.pageText;
delete aiContext.pageXml; delete aiContext.pageXml;
delete aiContext.truncated; delete aiContext.truncated;
delete aiContext.warnings; delete aiContext.warnings;
}
if (!kinds.selection) {
delete aiContext.selectedText; delete aiContext.selectedText;
delete aiContext.selectedBlockIds; delete aiContext.selectedBlockIds;
delete aiContext.selectedBlocks; delete aiContext.selectedBlocks;
delete aiContext.allowedTargetBlockIds; delete aiContext.allowedTargetBlockIds;
}
cloned.aiContext = aiContext; cloned.aiContext = aiContext;
return cloned; return cloned;
} }
@@ -754,7 +814,7 @@ export function createSidebarPageAiRuntime(context) {
pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope, pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope,
evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null, evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null,
pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null, pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null,
contentAccess: 'mnote.doc.fetch', contentAccess: 'mnote.context.read_current_page',
aiContext: aiContext aiContext: aiContext
}, },
editorTarget: editorTarget, editorTarget: editorTarget,
@@ -1532,13 +1592,17 @@ export function createSidebarPageAiRuntime(context) {
skills: pageAiNormalizeArray(category && category.skills).map(function(skill) { skills: pageAiNormalizeArray(category && category.skills).map(function(skill) {
return { return {
name: String(skill && skill.name || '').trim(), 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(), description: String(skill && skill.description || '').trim(),
enabled: skill && skill.enabled !== false, enabled: skill && skill.enabled !== false,
toggleable: skill && skill.toggleable !== false,
source: String(skill && skill.source || 'local').trim() || 'local', source: String(skill && skill.source || 'local').trim() || 'local',
origin: String(skill && skill.origin || '').trim(), origin: String(skill && skill.origin || '').trim(),
createdBy: String(skill && skill.createdBy || '').trim(), createdBy: String(skill && skill.createdBy || '').trim(),
patchCount: Number(skill && skill.patchCount || 0), 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) { archived: archived.map(function(skill) {
return { return {
name: String(skill && skill.name || '').trim(), 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(), description: String(skill && skill.description || '').trim(),
enabled: skill && skill.enabled !== false, enabled: skill && skill.enabled !== false,
toggleable: skill && skill.toggleable !== false,
source: String(skill && skill.source || 'local').trim() || 'local', source: String(skill && skill.source || 'local').trim() || 'local',
origin: String(skill && skill.origin || '').trim(), origin: String(skill && skill.origin || '').trim(),
createdBy: String(skill && skill.createdBy || '').trim(), createdBy: String(skill && skill.createdBy || '').trim(),
@@ -1565,8 +1632,11 @@ export function createSidebarPageAiRuntime(context) {
result.push({ result.push({
category: category.name, category: category.name,
name: skill.name, name: skill.name,
id: skill.id || skill.name,
title: skill.title || skill.name,
description: skill.description, description: skill.description,
enabled: skill.enabled !== false, enabled: skill.enabled !== false,
toggleable: skill.toggleable !== false,
source: skill.source || 'local', source: skill.source || 'local',
origin: skill.origin || '', origin: skill.origin || '',
createdBy: skill.createdBy || '', createdBy: skill.createdBy || '',
@@ -1666,6 +1736,74 @@ export function createSidebarPageAiRuntime(context) {
}).filter(Boolean).join('\n'); }).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) { function pageAiToolEventDeepFindString(value, keys, depth) {
if (!value || typeof value !== 'object' || depth > 5) return ''; if (!value || typeof value !== 'object' || depth > 5) return '';
for (var index = 0; index < keys.length; index += 1) { for (var index = 0; index < keys.length; index += 1) {
@@ -1992,9 +2130,11 @@ export function createSidebarPageAiRuntime(context) {
function pageAiFilteredSkillEntries() { function pageAiFilteredSkillEntries() {
var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase(); 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; 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.description || '').toLowerCase().indexOf(query) >= 0
|| String(skill.category || '').toLowerCase().indexOf(query) >= 0 || String(skill.category || '').toLowerCase().indexOf(query) >= 0
|| pageAiSkillOriginLabel(skill).toLowerCase().indexOf(query) >= 0; || pageAiSkillOriginLabel(skill).toLowerCase().indexOf(query) >= 0;
@@ -2018,6 +2158,162 @@ export function createSidebarPageAiRuntime(context) {
return '本地'; 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() { async function pageAiLoadProfiles() {
try { try {
var response = await fetch('/api/hermes/client/profiles', { var response = await fetch('/api/hermes/client/profiles', {
@@ -2050,6 +2346,12 @@ export function createSidebarPageAiRuntime(context) {
var next = String(profileName || '').trim(); var next = String(profileName || '').trim();
if (!next) return; if (!next) return;
pageAiSetActiveProfile(next); 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(); renderPageAiProviderButtons();
renderPageAiControls(); renderPageAiControls();
try { try {
@@ -2121,59 +2423,81 @@ export function createSidebarPageAiRuntime(context) {
} }
async function pageAiLoadSkills() { async function pageAiLoadSkills() {
var requestedProfile = pageAiCurrentProfile();
var loadSeq = (Number(pageUiState.pageAiSkillLoadSeq || 0) || 0) + 1;
pageUiState.pageAiSkillLoadSeq = loadSeq;
try { try {
var runtime = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim(); var catalogs = await Promise.all([
var params = runtime === 'reasonix' pageAiLoadSkillCatalog('mnote', ''),
? 'runtime=reasonix' pageAiLoadSkillCatalog('reasonix', ''),
: 'profile=' + encodeURIComponent(pageAiCurrentProfile()); pageAiLoadSkillCatalog('hermes', requestedProfile)
var response = await fetch('/api/hermes/client/skills?' + params, { ]);
headers: { 'accept': 'application/json' } if (pageUiState.pageAiSkillLoadSeq !== loadSeq || pageAiCurrentProfile() !== requestedProfile) return;
}); pageUiState.pageAiSkillCatalogs = {
var payload = await response.json().catch(function(){ return null; }); mnote: catalogs[0],
if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skills_failed_' + response.status)); reasonix: catalogs[1],
pageUiState.pageAiSkills = pageAiNormalizeSkills(payload); hermes: catalogs[2]
};
pageUiState.pageAiSkills = catalogs[2];
pageUiState.pageAiSkillError = ''; pageUiState.pageAiSkillError = '';
} catch (error) { } catch (error) {
if (pageUiState.pageAiSkillLoadSeq !== loadSeq || pageAiCurrentProfile() !== requestedProfile) return;
pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error);
pageUiState.pageAiSkills = { categories: [], archived: [] }; pageUiState.pageAiSkills = { categories: [], archived: [] };
pageUiState.pageAiSkillCatalogs = pageUiState.pageAiSkillCatalogs || { mnote: { categories: [], archived: [] }, reasonix: { categories: [], archived: [] }, hermes: { categories: [], archived: [] } };
} }
renderPageAiControls(); renderPageAiControls();
renderPageAiConversation(); renderPageAiConversation();
} }
async function pageAiToggleSkill(skillName, enabled) { async function pageAiToggleSkill(skillName, enabled, group, profile) {
var name = String(skillName || '').trim(); var name = String(skillName || '').trim();
if (!name) return; 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; var previous = null;
pageAiSkillListEntries().forEach(function(skill) { pageAiToggleableSkillEntries('hermes', pageAiCurrentProfile()).forEach(function(skill) {
if (skill.name === name && previous == null) previous = skill.enabled !== false; if (skill.id === name || skill.name === name) previous = skill.enabled !== false;
}); });
try { try {
var response = await fetch('/api/hermes/client/skills/toggle', { var response = await fetch('/api/hermes/client/skills/toggle', {
method: 'PUT', method: 'PUT',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
profile: pageAiCurrentProfile(), profile: skillProfile || pageAiCurrentProfile(),
name: name, name: name,
enabled: Boolean(enabled) enabled: Boolean(enabled)
}) })
}); });
var payload = await response.json().catch(function(){ return null; }); var payload = await response.json().catch(function(){ return null; });
if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skill_toggle_failed_' + response.status)); 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) { 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); document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', name);
pageUiState.pageAiSkillError = ''; pageUiState.pageAiSkillError = '';
} catch (error) { } catch (error) {
pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error);
if (previous != null) { 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) { 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 drawer = ensurePageAiDrawer();
var isAcp = pageUiState.pageAiAcpRuntime !== ''; var isAcp = pageUiState.pageAiAcpRuntime !== '';
var activeAgentId = pageAiCurrentAgentId(); var activeAgentId = pageAiCurrentAgentId();
var activeProfile = pageAiRunProfile(); var activeProfile = pageAiCurrentProfile();
drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat'); drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat');
drawer.setAttribute('data-page-ai-active-agent-id', activeAgentId); drawer.setAttribute('data-page-ai-active-agent-id', activeAgentId);
drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || 'reasonix'); drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || 'reasonix');
document.documentElement.setAttribute('data-mnote-page-ai-agent-id', activeAgentId); document.documentElement.setAttribute('data-mnote-page-ai-agent-id', activeAgentId);
var agentSelector = drawer.querySelector('[data-page-ai-agent-selector]'); var agentButton = drawer.querySelector('[data-page-ai-agent-button]');
if (agentSelector instanceof HTMLElement) { var agentPopoverId = 'mnote-page-ai-agent-popover';
agentSelector.innerHTML = PAGE_AI_AGENT_REGISTRY.map(function(agent) { if (agentButton instanceof HTMLElement) {
var active = agent.id === activeAgentId; var activeAgent = pageAiAgentRecord(activeAgentId);
return '<button type="button" class="wolai-page-ai-tab' + (active ? ' is-active' : '') + '" data-page-ai-agent-id="' + escapeHtml(agent.id) + '" aria-pressed="' + (active ? 'true' : 'false') + '">' + escapeHtml(agent.label) + '</button>'; agentButton.textContent = 'AI';
}).join(''); 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]'); var agentPopover = drawer.querySelector('[data-page-ai-agent-popover]');
if (contextRefs instanceof HTMLElement) { if (agentPopover instanceof HTMLElement) {
agentPopover.id = agentPopoverId;
agentPopover.hidden = !pageUiState.pageAiAgentPopoverOpen;
agentPopover.innerHTML = '' +
'<div class="wolai-page-ai-context-popover-head">' +
'<strong>选择 Agent</strong>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="close-agent-popover">完成</button>' +
'</div>' +
'<div class="wolai-page-ai-agent-option-list">' +
PAGE_AI_AGENT_REGISTRY.map(function(agent) {
var active = agent.id === activeAgentId;
var detail = agent.canWriteFiles ? '可读取并在授权目录内写文件' : '只聊天,不申请文件写权限';
return '' +
'<button type="button" class="wolai-page-ai-agent-option' + (active ? ' is-active' : '') + '" data-page-ai-agent-id="' + escapeHtml(agent.id) + '" aria-pressed="' + (active ? 'true' : 'false') + '">' +
'<span class="wolai-page-ai-agent-option-label">' + escapeHtml(agent.label) + '</span>' +
'<span class="wolai-page-ai-agent-option-detail">' + escapeHtml(detail) + '</span>' +
'</button>';
}).join('') +
'</div>' +
(activeAgentId === 'hermes'
? '<label class="wolai-page-ai-agent-profile"><span>Hermes profile</span><select data-page-ai-profile-select></select></label>'
: '');
}
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(); var selectedRefs = pageAiEnsureContextRefState();
contextRefs.innerHTML = PAGE_AI_CONTEXT_REF_REGISTRY.map(function(ref) { contextPopover.id = contextPopoverId;
var active = selectedRefs[ref.id] !== false && selectedRefs[ref.id] === true; contextPopover.hidden = !pageUiState.pageAiContextPopoverOpen;
return '<button type="button" class="wolai-page-ai-ghost' + (active ? ' is-active' : '') + '" data-page-ai-context-ref="' + escapeHtml(ref.id) + '" aria-pressed="' + (active ? 'true' : 'false') + '">' + escapeHtml(ref.label) + '</button>'; contextPopover.innerHTML = '' +
}).join(''); '<div class="wolai-page-ai-context-popover-head">' +
'<strong>发送给 AI 的上下文</strong>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="close-context-popover">完成</button>' +
'</div>' +
'<div class="wolai-page-ai-context-option-list">' +
PAGE_AI_CONTEXT_REF_REGISTRY.map(function(ref) {
var checked = selectedRefs[ref.id] === true;
var disabled = pageAiContextRefDisabled(ref.id);
return '' +
'<label class="wolai-page-ai-context-option' + (disabled ? ' is-disabled' : '') + '">' +
'<input type="checkbox" data-page-ai-context-ref="' + escapeHtml(ref.id) + '"' + (checked ? ' checked' : '') + (disabled ? ' disabled' : '') + ' />' +
'<span class="wolai-page-ai-context-option-main">' +
'<span class="wolai-page-ai-context-option-label">' + escapeHtml(ref.label) + '</span>' +
'<span class="wolai-page-ai-context-option-detail">' + escapeHtml(pageAiContextRefDetail(ref.id)) + '</span>' +
'</span>' +
'</label>';
}).join('') +
'</div>' +
'<div class="wolai-page-ai-context-popover-foot">' +
'<span>授权区域</span>' +
'<span>' + escapeHtml(pageAiBuildAllowedRoots().length ? pageAiBuildAllowedRoots().map(function(root) { return root.permission + ' · ' + root.rootUri.replace(/^file:\/\//, ''); }).join(' / ') : '未选择授权区域') + '</span>' +
'</div>';
} }
var allowedRootsNode = drawer.querySelector('[data-page-ai-allowed-roots]'); var allowedRootsNode = drawer.querySelector('[data-page-ai-allowed-roots]');
if (allowedRootsNode instanceof HTMLElement) { if (allowedRootsNode instanceof HTMLElement) {
@@ -2424,29 +2808,40 @@ export function createSidebarPageAiRuntime(context) {
if (skillList instanceof HTMLElement) { if (skillList instanceof HTMLElement) {
var skills = pageAiFilteredSkillEntries(); var skills = pageAiFilteredSkillEntries();
if (!skills.length) { if (!skills.length) {
var emptyText = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix' skillList.innerHTML = '<div class="wolai-page-ai-empty">没有匹配的技能。</div>';
? '没有匹配的 Reasonix skill。'
: '没有匹配的 Hermes skill。';
skillList.innerHTML = '<div class="wolai-page-ai-empty">' + escapeHtml(emptyText) + '</div>';
} else { } else {
skillList.innerHTML = skills.map(function(skill) { 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 '' +
'<section class="wolai-page-ai-skill-group' + (collapsed ? ' is-collapsed' : '') + '" data-page-ai-skill-group="' + escapeHtml(group) + '">' +
'<button type="button" class="wolai-page-ai-skill-group-head" data-page-ai-skill-group-toggle="' + escapeHtml(group) + '" aria-expanded="' + (collapsed ? 'false' : 'true') + '">' +
'<span class="wolai-page-ai-skill-group-title"><span class="wolai-page-ai-skill-group-chevron">' + (collapsed ? '' : '⌄') + '</span>' + escapeHtml(groupLabels[group] || group) + '</span>' +
'<span>' + escapeHtml(String(groupSkills.length) + headingExtra) + '</span>' +
'</button>' +
(collapsed ? '' : groupSkills.map(function(skill) {
var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : ''); var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : '');
var description = String(skill.description || '').trim(); var description = String(skill.description || '').trim();
var hasDescription = description && description !== '---' && description !== '无描述'; var hasDescription = description && description !== '---' && description !== '无描述';
var canToggle = skill.toggleable !== false && String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() !== 'reasonix'; var displayName = String(skill.title || skill.name || skill.id || '').trim();
return '' + return '' +
'<div class="wolai-page-ai-skill-row">' + '<div class="wolai-page-ai-skill-row">' +
'<div class="wolai-page-ai-skill-copy">' + '<div class="wolai-page-ai-skill-copy">' +
'<div class="wolai-page-ai-skill-main">' + '<div class="wolai-page-ai-skill-main">' +
'<div class="wolai-page-ai-skill-name">' + escapeHtml(skill.name) + '</div>' + '<div class="wolai-page-ai-skill-name">' + escapeHtml(displayName) + '</div>' +
'<div class="wolai-page-ai-skill-source">' + escapeHtml(sourceText) + '</div>' + '<div class="wolai-page-ai-skill-source">' + escapeHtml(sourceText) + '</div>' +
'</div>' + '</div>' +
(hasDescription ? '<div class="wolai-page-ai-skill-desc">' + escapeHtml(description) + '</div>' : '') + (hasDescription ? '<div class="wolai-page-ai-skill-desc">' + escapeHtml(description) + '</div>' : '') +
'</div>' + '</div>' +
'<button type="button" class="wolai-page-ai-skill-switch' + (skill.enabled !== false ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.name) + '" aria-pressed="' + (skill.enabled !== false ? 'true' : 'false') + '"' + (canToggle ? '' : ' disabled title="Reasonix skills 当前为只读展示"') + '>' + '<button type="button" class="wolai-page-ai-skill-switch' + (pageAiSkillEnabled(skill) ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.id || skill.name) + '" data-page-ai-skill-group="' + escapeHtml(group) + '" data-page-ai-skill-profile="' + escapeHtml(skill.profile || '') + '" aria-pressed="' + (pageAiSkillEnabled(skill) ? 'true' : 'false') + '"' + (skill.toggleable === false ? ' disabled' : '') + '>' +
'<span></span>' + '<span></span>' +
'</button>' + '</button>' +
'</div>'; '</div>';
}).join('')) +
'</section>';
}).join(''); }).join('');
} }
} }
@@ -2574,6 +2969,14 @@ export function createSidebarPageAiRuntime(context) {
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="wolai-page-ai-header-actions">' + '<div class="wolai-page-ai-header-actions">' +
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="skills" aria-label="技能" title="技能">' +
'<svg class="wolai-page-ai-icon-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
'<path d="M12 3l1.35 4.15L17.5 8.5l-4.15 1.35L12 14l-1.35-4.15L6.5 8.5l4.15-1.35L12 3z" />' +
'<path d="M18.5 13l.8 2.4 2.2.8-2.2.8-.8 2.4-.8-2.4-2.2-.8 2.2-.8.8-2.4z" />' +
'<path d="M5.5 14l.65 1.85L8 16.5l-1.85.65L5.5 19l-.65-1.85L3 16.5l1.85-.65L5.5 14z" />' +
'</svg>' +
'</button>' +
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="history" aria-label="历史会话">⌕</button>' +
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="agent" aria-label="打开页面 AI 设置">⚙</button>' + '<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="agent" aria-label="打开页面 AI 设置">⚙</button>' +
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' + '<button type="button" class="wolai-page-ai-icon" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
'</div>' + '</div>' +
@@ -2581,9 +2984,9 @@ export function createSidebarPageAiRuntime(context) {
'<div class="wolai-page-ai-body">' + '<div class="wolai-page-ai-body">' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="chat">' + '<section class="wolai-page-ai-panel-section" data-page-ai-panel="chat">' +
'<div class="wolai-page-ai-chat-meta">' + '<div class="wolai-page-ai-chat-meta">' +
'<button type="button" class="wolai-page-ai-session-button" data-page-ai-action="history">' + '<span class="wolai-page-ai-session-summary">' +
'<span data-page-ai-session-status>等待 AI session</span>' + '<span data-page-ai-session-status>等待 AI session</span>' +
'</button>' + '</span>' +
'<span data-page-ai-context-scope-label>当前页</span>' + '<span data-page-ai-context-scope-label>当前页</span>' +
'<span data-page-ai-run-status>空闲</span>' + '<span data-page-ai-run-status>空闲</span>' +
'</div>' + '</div>' +
@@ -2681,6 +3084,14 @@ export function createSidebarPageAiRuntime(context) {
'<span>搜索技能</span>' + '<span>搜索技能</span>' +
'<input type="search" data-page-ai-skill-search placeholder="搜索技能…" />' + '<input type="search" data-page-ai-skill-search placeholder="搜索技能…" />' +
'</label>' + '</label>' +
'<label class="wolai-page-ai-profile-select" data-page-ai-skills-hermes-profile>' +
'<span>Hermes profile</span>' +
'<select data-page-ai-profile-select></select>' +
'</label>' +
'<label class="wolai-page-ai-skill-filter-toggle">' +
'<input type="checkbox" data-page-ai-hide-hermes-builtin' + (pageAiHideHermesBuiltinSkills(pageAiCurrentProfile()) ? ' checked' : '') + ' />' +
'<span>隐藏 Hermes 内置</span>' +
'</label>' +
'</div>' + '</div>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-skill-error hidden></div>' + '<div class="wolai-page-ai-inline-error" data-page-ai-skill-error hidden></div>' +
'<div class="wolai-page-ai-skill-list" data-page-ai-skill-list></div>' + '<div class="wolai-page-ai-skill-list" data-page-ai-skill-list></div>' +
@@ -2742,13 +3153,17 @@ export function createSidebarPageAiRuntime(context) {
'</div>' + '</div>' +
'<div class="wolai-page-ai-footer">' + '<div class="wolai-page-ai-footer">' +
'<div class="wolai-page-ai-composer">' + '<div class="wolai-page-ai-composer">' +
'<div class="wolai-page-ai-agent-selector" data-page-ai-agent-selector></div>' +
'<div class="wolai-page-ai-context-refs" data-page-ai-context-refs></div>' +
'<div class="wolai-page-ai-allowed-roots" data-page-ai-allowed-roots></div>' +
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="输入消息…"></textarea>' + '<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="输入消息…"></textarea>' +
'<div class="wolai-page-ai-composer-bar">' + '<div class="wolai-page-ai-composer-bar">' +
'<button type="button" class="wolai-page-ai-tool-button" data-page-ai-action="new-session" title="新会话"></button>' + '<button type="button" class="wolai-page-ai-tool-button" data-page-ai-action="new-session" title="新会话"></button>' +
'<button type="button" class="wolai-page-ai-tool-button" data-page-ai-action="history" title="历史会话">⌕</button>' + '<div class="wolai-page-ai-agent-picker">' +
'<button type="button" class="wolai-page-ai-tool-button wolai-page-ai-agent-button" data-page-ai-agent-button aria-expanded="false" aria-label="AgentReasonix" title="AgentReasonix">AI</button>' +
'<div class="wolai-page-ai-agent-popover" data-page-ai-agent-popover hidden></div>' +
'</div>' +
'<div class="wolai-page-ai-context-picker">' +
'<button type="button" class="wolai-page-ai-tool-button wolai-page-ai-context-button" data-page-ai-context-button aria-expanded="false" aria-label="上下文:当前页 + 打开资源" title="上下文:当前页 + 打开资源">⇅</button>' +
'<div class="wolai-page-ai-context-popover" data-page-ai-context-popover hidden></div>' +
'</div>' +
'<span class="wolai-page-ai-composer-spacer"></span>' + '<span class="wolai-page-ai-composer-spacer"></span>' +
'<button type="button" class="wolai-page-ai-stop" data-page-ai-action="stop-run" disabled>停止</button>' + '<button type="button" class="wolai-page-ai-stop" data-page-ai-action="stop-run" disabled>停止</button>' +
'<button type="button" class="wolai-page-ai-send" data-page-ai-action="send" aria-label="发送">发送</button>' + '<button type="button" class="wolai-page-ai-send" data-page-ai-action="send" aria-label="发送">发送</button>' +
@@ -3177,13 +3592,18 @@ export function createSidebarPageAiRuntime(context) {
contextScope: pageUiState.pageAiContextScope, contextScope: pageUiState.pageAiContextScope,
contextRefs: contextRefs, contextRefs: contextRefs,
allowedRoots: allowedRoots, allowedRoots: allowedRoots,
skillPreferences: {
mnote: pageAiSkillPreferenceTable('mnote', ''),
reasonix: pageAiSkillPreferenceTable('reasonix', ''),
hermes: pageAiSkillPreferenceTable('hermes', pageAiCurrentProfile())
},
message: prompt, message: prompt,
model: pageAiMnoteToolModel(), model: pageAiMnoteToolModel(),
pageContext: requestPageContext, pageContext: requestPageContext,
editorTarget: scopedContext.editorTarget, editorTarget: scopedContext.editorTarget,
runTargetSnapshot: runTargetSnapshot, runTargetSnapshot: runTargetSnapshot,
selectedBlockId: scopedContext.selectedBlockId, selectedBlockId: scopedContext.selectedBlockId,
selectedText: pageAiContextKindsFromRefs(contextRefs).selection ? scopedContext.selectedText : '', selectedText: '',
traceId: 'page-ai-run-' + Date.now().toString(36) traceId: 'page-ai-run-' + Date.now().toString(36)
}) })
}); });
@@ -3282,6 +3702,9 @@ export function createSidebarPageAiRuntime(context) {
if (completedSession) completedSession.usage = completed.usage; if (completedSession) completedSession.usage = completed.usage;
} }
var agentAudit = completed && completed.agentAudit && typeof completed.agentAudit === 'object' ? completed.agentAudit : null; 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) { var changedFiles = pageAiNormalizeArray(agentAudit && agentAudit.changedFiles).map(function(file) {
if (!file || typeof file !== 'object') return file; if (!file || typeof file !== 'object') return file;
return Object.assign({ return Object.assign({
@@ -3307,26 +3730,10 @@ export function createSidebarPageAiRuntime(context) {
traceId: runTraceId, traceId: runTraceId,
auditId: String(agentAudit.eventId || '') auditId: String(agentAudit.eventId || '')
}); });
}
if (agentRunReceipt || changedFiles.length) {
try { try {
var currentId = currentDocumentId(); pageAiDispatchReceiptRefresh(agentRunReceipt, changedFiles, agentAudit, runId, runTraceId);
var currentPath = String(currentId || '').replace(/^local-md:/, '').replace(/~2F/g, '/');
var touchesCurrent = changedFiles.some(function(file) {
var path = String(file && (file.documentId || file.path || file.filePath || '') || '');
return path === currentId || (currentPath && path.indexOf(currentPath) >= 0);
});
if (touchesCurrent) {
window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', {
detail: {
toolName: 'agent.changed_files',
normalizedToolName: 'agent.changed_files',
documentId: currentId,
workspaceId: resolveWorkspaceId(document.body),
runId: runId,
traceId: runTraceId,
toolCallId: runId + ':agent.changed_files'
}
}));
}
} catch (_) {} } catch (_) {}
} }
} catch (_) {} } catch (_) {}
@@ -3447,9 +3854,13 @@ export function createSidebarPageAiRuntime(context) {
pageAiLoadProfileMemory: (...args) => pageAiLoadProfileMemory(...args), pageAiLoadProfileMemory: (...args) => pageAiLoadProfileMemory(...args),
pageAiLoadSkills: (...args) => pageAiLoadSkills(...args), pageAiLoadSkills: (...args) => pageAiLoadSkills(...args),
pageAiSwitchProfile: (...args) => pageAiSwitchProfile(...args), pageAiSwitchProfile: (...args) => pageAiSwitchProfile(...args),
pageAiToggleSkillGroup: (...args) => pageAiToggleSkillGroup(...args),
pageAiSetHideHermesBuiltinSkills: (...args) => pageAiSetHideHermesBuiltinSkills(...args),
pageAiSetContextScope: (...args) => pageAiSetContextScope(...args), pageAiSetContextScope: (...args) => pageAiSetContextScope(...args),
pageAiSetAgentId: (...args) => pageAiSetAgentId(...args), pageAiSetAgentId: (...args) => pageAiSetAgentId(...args),
pageAiToggleContextRef: (...args) => pageAiToggleContextRef(...args), pageAiToggleContextRef: (...args) => pageAiToggleContextRef(...args),
pageAiSetContextPopoverOpen: (...args) => pageAiSetContextPopoverOpen(...args),
pageAiSetAgentPopoverOpen: (...args) => pageAiSetAgentPopoverOpen(...args),
pageAiLoadAllowedRoots: (...args) => pageAiLoadAllowedRoots(...args), pageAiLoadAllowedRoots: (...args) => pageAiLoadAllowedRoots(...args),
updatePageAiTriggerState: (...args) => updatePageAiTriggerState(...args) updatePageAiTriggerState: (...args) => updatePageAiTriggerState(...args)
}; };
@@ -59,8 +59,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' }, pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' },
pageAiProfileMemoryError: '', pageAiProfileMemoryError: '',
pageAiSkills: { categories: [], archived: [] }, pageAiSkills: { categories: [], archived: [] },
pageAiSkillCatalogs: { mnote: { categories: [], archived: [] }, reasonix: { categories: [], archived: [] }, hermes: { categories: [], archived: [] } },
pageAiSkillPreferences: {},
pageAiCollapsedSkillGroups: {},
pageAiSkillQuery: '', pageAiSkillQuery: '',
pageAiSkillError: '', pageAiSkillError: '',
pageAiSkillLoadSeq: 0,
pageAiSessions: [], pageAiSessions: [],
pageAiActiveSessionId: '', pageAiActiveSessionId: '',
pageAiSessionSearchQuery: '', pageAiSessionSearchQuery: '',
@@ -2378,9 +2382,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args); const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args);
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args); const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args); const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args);
const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args);
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args); const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args); const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args); const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
const pageAiSetAgentPopoverOpen = (...args) => sidebarPageAi.pageAiSetAgentPopoverOpen(...args);
const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args); const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({ const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
@@ -2593,6 +2600,36 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return; return;
} }
var pageAiAgentButton = closestAction(e.target, '[data-page-ai-agent-button]');
if (pageAiAgentButton) {
e.preventDefault();
pageAiSetAgentPopoverOpen(pageAiAgentButton.getAttribute('aria-expanded') !== 'true');
return;
}
var pageAiAgentPopoverClose = closestAction(e.target, '[data-page-ai-action="close-agent-popover"]');
if (pageAiAgentPopoverClose) {
e.preventDefault();
pageAiSetAgentPopoverOpen(false);
return;
}
var pageAiContextButton = closestAction(e.target, '[data-page-ai-context-button]');
if (pageAiContextButton) {
e.preventDefault();
sidebarPageAi.pageAiSetContextPopoverOpen(
pageAiContextButton.getAttribute('aria-expanded') !== 'true'
);
return;
}
var pageAiContextPopoverClose = closestAction(e.target, '[data-page-ai-action="close-context-popover"]');
if (pageAiContextPopoverClose) {
e.preventDefault();
sidebarPageAi.pageAiSetContextPopoverOpen(false);
return;
}
var pageAiContextRef = closestAction(e.target, '[data-page-ai-context-ref]'); var pageAiContextRef = closestAction(e.target, '[data-page-ai-context-ref]');
if (pageAiContextRef) { if (pageAiContextRef) {
e.preventDefault(); e.preventDefault();
@@ -2611,8 +2648,17 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (pageAiSkillToggle) { if (pageAiSkillToggle) {
e.preventDefault(); e.preventDefault();
var skillName = pageAiSkillToggle.getAttribute('data-page-ai-skill-toggle') || ''; var skillName = pageAiSkillToggle.getAttribute('data-page-ai-skill-toggle') || '';
var skillGroup = pageAiSkillToggle.getAttribute('data-page-ai-skill-group') || '';
var skillProfile = pageAiSkillToggle.getAttribute('data-page-ai-skill-profile') || '';
var nextEnabled = pageAiSkillToggle.getAttribute('aria-pressed') !== 'true'; var nextEnabled = pageAiSkillToggle.getAttribute('aria-pressed') !== 'true';
void pageAiToggleSkill(skillName, nextEnabled); void pageAiToggleSkill(skillName, nextEnabled, skillGroup, skillProfile);
return;
}
var pageAiSkillGroupToggle = closestAction(e.target, '[data-page-ai-skill-group-toggle]');
if (pageAiSkillGroupToggle) {
e.preventDefault();
pageAiToggleSkillGroup(pageAiSkillGroupToggle.getAttribute('data-page-ai-skill-group-toggle') || '');
return; return;
} }
@@ -3128,6 +3174,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
void pageAiSwitchProfile(pageAiProfileSelect.value); void pageAiSwitchProfile(pageAiProfileSelect.value);
return; return;
} }
var pageAiHideHermesBuiltin = closestAction(event.target, '[data-page-ai-hide-hermes-builtin]');
if (pageAiHideHermesBuiltin instanceof HTMLInputElement) {
pageAiSetHideHermesBuiltinSkills(pageAiHideHermesBuiltin.checked);
return;
}
var pageAiContextSelect = closestAction(event.target, '[data-page-ai-context-scope]'); var pageAiContextSelect = closestAction(event.target, '[data-page-ai-context-scope]');
if (pageAiContextSelect instanceof HTMLSelectElement) { if (pageAiContextSelect instanceof HTMLSelectElement) {
pageAiSetContextScope(pageAiContextSelect.value); pageAiSetContextScope(pageAiContextSelect.value);
@@ -68,6 +68,7 @@ pub struct AcpMnoteToolContext {
pub trace_id: Option<String>, pub trace_id: Option<String>,
pub workspace_id: Option<String>, pub workspace_id: Option<String>,
pub document_id: Option<String>, pub document_id: Option<String>,
pub mnote_capabilities: Option<Value>,
} }
/// Handler for session events. /// Handler for session events.
@@ -567,6 +568,7 @@ impl AcpSessionManager {
trace_id: mnote_context.trace_id, trace_id: mnote_context.trace_id,
workspace_id: mnote_context.workspace_id, workspace_id: mnote_context.workspace_id,
document_id: mnote_context.document_id, document_id: mnote_context.document_id,
mnote_capabilities: mnote_context.mnote_capabilities,
}; };
debug!("ACP session/prompt (session={})", session_id); debug!("ACP session/prompt (session={})", session_id);
+2
View File
@@ -193,6 +193,8 @@ pub struct SessionPromptParams {
pub workspace_id: Option<String>, pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>, pub document_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mnote_capabilities: Option<Value>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -0,0 +1,127 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use serde_json::{json, Value};
pub async fn context_snapshot(
_state: &AppState,
_context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let context_refs = input
.arg_value("contextRefs")
.or_else(|| input.arg_value("context_refs"))
.unwrap_or_else(|| json!([]));
Ok(json!({
"ok": true,
"schema": "mnote.context_snapshot.v1",
"runId": input.run_id,
"sessionId": input.session_id,
"workspace": {
"workspaceId": input.effective_workspace_id(),
"sourceKind": input.effective_source_kind(),
"rootUri": input.effective_root_uri()
},
"contextRefs": context_refs,
"primaryTarget": {
"documentId": input.effective_document_id(),
"fileVersion": input.arg_value("fileVersion").or_else(|| input.arg_value("file_version"))
},
"availableReads": {
"currentPage": context_ref_enabled(input, "current_page"),
"selection": context_ref_enabled(input, "selection"),
"folder": context_ref_enabled(input, "folder"),
"changedFiles": context_ref_enabled(input, "changed_files")
}
}))
}
pub async fn resolve_target(
_state: &AppState,
_context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
Ok(json!({
"ok": true,
"schema": "mnote.context_target.v1",
"workspaceId": input.effective_workspace_id(),
"documentId": input.effective_document_id(),
"sourceKind": input.effective_source_kind(),
"rootUri": input.effective_root_uri(),
"relativePath": input.arg_string("relativePath").or_else(|| input.arg_string("relative_path")),
"fileVersion": input.arg_value("fileVersion").or_else(|| input.arg_value("file_version")),
"contextRefs": input.arg_value("contextRefs").or_else(|| input.arg_value("context_refs")).unwrap_or_else(|| json!([]))
}))
}
pub async fn read_current_page(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
if !context_ref_enabled(input, "current_page") {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_context_current_page_not_allowed",
"本次 run 未授权 current_page 上下文",
)
.with_context(context));
}
doc::doc_fetch(state, context, input).await
}
fn context_ref_enabled(input: &ToolCallInput, expected: &str) -> bool {
input
.arg_value("contextRefs")
.or_else(|| input.arg_value("context_refs"))
.and_then(|value| value.as_array().cloned())
.map(|items| {
items.iter().any(|item| {
item.as_str() == Some(expected)
|| item.get("kind").and_then(Value::as_str).map(str::trim) == Some(expected)
})
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn input_with_refs(context_refs: Value) -> ToolCallInput {
ToolCallInput {
tool_name: "mnote.context.read_current_page".into(),
workspace_id: Some("ws_1".into()),
document_id: Some("doc_1".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote".into()),
actor_id: Some("user_1".into()),
profile: None,
session_id: Some("sess_1".into()),
run_id: Some("run_1".into()),
tool_call_id: Some("tool_1".into()),
trace_id: Some("trace_1".into()),
idempotency_key: None,
dry_run: None,
capability_scope: Some(vec!["context.read".into()]),
args: Some(json!({ "contextRefs": context_refs })),
}
}
#[test]
fn context_ref_enabled_accepts_string_and_object_refs() {
assert!(context_ref_enabled(
&input_with_refs(json!(["current_page"])),
"current_page"
));
assert!(context_ref_enabled(
&input_with_refs(json!([{ "kind": "current_page" }])),
"current_page"
));
assert!(!context_ref_enabled(
&input_with_refs(json!(["selection"])),
"current_page"
));
}
}
@@ -13,6 +13,10 @@ pub fn manifest() -> Value {
"writeOwner": "rust-runtime-kernel" "writeOwner": "rust-runtime-kernel"
}, },
"tools": [ "tools": [
skill_read_tool(),
context_snapshot_tool(),
context_read_current_page_tool(),
context_resolve_target_tool(),
doc_fetch_tool(), doc_fetch_tool(),
doc_find_tool(), doc_find_tool(),
block_fetch_tool(), block_fetch_tool(),
@@ -37,6 +41,95 @@ pub fn manifest() -> Value {
}) })
} }
fn skill_read_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("skillId".into(), json!({ "type": "string" }));
map.insert("agentId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.skill.read",
"description": "按需读取 MNote skill 正文。默认 prompt 只列 skill 摘要,正文必须通过本工具懒加载。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["skill.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["skillId"],
"properties": properties
}
})
}
fn context_snapshot_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert(
"contextRefs".into(),
json!({ "type": "array", "items": { "type": ["string", "object"] } }),
);
}
json!({
"name": "mnote.context.snapshot",
"description": "返回本次 run 可用的 MNote 上下文摘要,不返回页面正文全文。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["context.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"properties": properties
}
})
}
fn context_read_current_page_tool() -> Value {
let mut tool = doc_fetch_tool();
if let Value::Object(map) = &mut tool {
map.insert(
"name".into(),
Value::String("mnote.context.read_current_page".into()),
);
map.insert(
"description".into(),
Value::String("在 current_page contextRef 被授权时读取当前 Markdown 页面。".into()),
);
map.insert(
"capabilityScope".into(),
json!(["context.read", "page.read"]),
);
}
tool
}
fn context_resolve_target_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert(
"contextRefs".into(),
json!({ "type": "array", "items": { "type": ["string", "object"] } }),
);
map.insert("relativePath".into(), json!({ "type": "string" }));
map.insert(
"fileVersion".into(),
json!({ "type": ["string", "object"] }),
);
}
json!({
"name": "mnote.context.resolve_target",
"description": "解析当前 Page AI run 的工作区、文档、rootUri、relativePath 与 file version。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["context.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"properties": properties
}
})
}
fn base_identity_properties() -> Value { fn base_identity_properties() -> Value {
json!({ json!({
"workspaceId": { "type": "string" }, "workspaceId": { "type": "string" },
@@ -1,9 +1,11 @@
pub mod artifact; pub mod artifact;
pub mod block; pub mod block;
pub mod context_tools;
pub mod doc; pub mod doc;
pub mod manifest; pub mod manifest;
pub mod page; pub mod page;
pub mod resource; pub mod resource;
pub mod skill;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
@@ -0,0 +1,149 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use axum::http::StatusCode;
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MnoteSkill {
pub id: &'static str,
pub title: &'static str,
pub description: &'static str,
pub agent_ids: &'static [&'static str],
pub read_only: bool,
pub requires_context_refs: &'static [&'static str],
pub tool_names: &'static [&'static str],
pub content: &'static str,
}
const SKILLS: &[MnoteSkill] = &[
MnoteSkill {
id: "mnote-current-page",
title: "MNote current page",
description: "Read the current MNote Markdown page only when the task needs page content.",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["current_page"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.context.read_current_page",
],
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
},
MnoteSkill {
id: "mnote-local-file",
title: "MNote local file editing",
description: "Read and patch local Markdown files inside MNote allowed roots.",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder"],
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
},
MnoteSkill {
id: "mnote-chat-only",
title: "MNote chat only",
description: "Reply conversationally without reading or writing MNote page/file context.",
agent_ids: &["chat_only", "hermes", "reasonix"],
read_only: true,
requires_context_refs: &[],
tool_names: &[],
content: include_str!("../../../../../skills/mnote-chat-only/SKILL.md"),
},
];
pub fn skill_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
SKILLS
.iter()
.filter(|skill| skill_matches_agent(skill, agent_id))
.map(skill_summary)
.collect()
}
pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> {
let requested = skill_id.trim();
SKILLS
.iter()
.find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id))
}
pub async fn skill_read(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let skill_id = input
.arg_string("skillId")
.or_else(|| input.arg_string("skill_id"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_skill_id_required", "缺少 skillId")
.with_context(context)
})?;
let agent_id = input
.arg_string("agentId")
.or_else(|| input.arg_string("agent_id"));
let skill = find_skill(&skill_id, agent_id.as_deref()).ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"mnote_skill_not_found",
"未知或当前 agent 不可用的 MNote skill",
)
.with_context(context)
})?;
Ok(json!({
"ok": true,
"schema": "mnote.skill.v1",
"skill": skill_summary(skill),
"content": skill.content,
"tools": skill.tool_names,
"constraints": {
"allowedRootsRequired": skill.requires_context_refs.contains(&"folder"),
"mustReadBackAfterWrite": !skill.read_only
}
}))
}
fn skill_matches_agent(skill: &MnoteSkill, agent_id: Option<&str>) -> bool {
let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
};
skill
.agent_ids
.iter()
.any(|candidate| *candidate == agent_id)
}
fn skill_summary(skill: &MnoteSkill) -> Value {
json!({
"id": skill.id,
"title": skill.title,
"description": skill.description,
"agentIds": skill.agent_ids,
"readOnly": skill.read_only,
"requiresContextRefs": skill.requires_context_refs,
"toolNames": skill.tool_names
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_registry_filters_by_agent() {
let chat_skills = skill_summaries_for_agent(Some("chat_only"));
assert!(chat_skills
.iter()
.any(|skill| skill["id"] == "mnote-chat-only"));
assert!(!chat_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-file"));
}
#[test]
fn skill_lookup_rejects_unknown_or_agent_mismatch() {
assert!(find_skill("mnote-current-page", Some("reasonix")).is_some());
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
assert!(find_skill("missing", Some("reasonix")).is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,9 @@ use super::hermes_client;
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput}; use crate::hermes_tools::{
artifact, block, context_tools, doc, manifest, page, resource, skill, ToolCallInput,
};
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json; use axum::Json;
@@ -347,6 +349,14 @@ pub(crate) async fn execute_mnote_tool_call(
return Ok(cached); return Ok(cached);
} }
let result = match input.tool_name.as_str() { let result = match input.tool_name.as_str() {
"mnote.skill.read" => skill::skill_read(&context, &input).await,
"mnote.context.snapshot" => context_tools::context_snapshot(&state, &context, &input).await,
"mnote.context.read_current_page" => {
context_tools::read_current_page(&state, &context, &input).await
}
"mnote.context.resolve_target" => {
context_tools::resolve_target(&state, &context, &input).await
}
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await, "mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await, "mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await, "mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
@@ -538,6 +548,10 @@ fn is_read_tool(tool_name: &str) -> bool {
matches!( matches!(
tool_name, tool_name,
"mnote.page.get" "mnote.page.get"
| "mnote.skill.read"
| "mnote.context.snapshot"
| "mnote.context.read_current_page"
| "mnote.context.resolve_target"
| "mnote.doc.fetch" | "mnote.doc.fetch"
| "mnote.doc.find" | "mnote.doc.find"
| "mnote.block.fetch" | "mnote.block.fetch"
@@ -1359,6 +1373,80 @@ mod tests {
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
} }
#[tokio::test]
async fn hermes_tools_context_read_current_page_reads_local_markdown() {
let root = std::env::temp_dir().join(format!(
"mnote-context-read-current-page-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("root");
fs::write(
root.join("README.md"),
"# 当前页\n\n来自 mnote.context.read_current_page 的正文。\n",
)
.expect("markdown");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.context.read_current_page",
"workspaceId": "local-ws-context",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"profile": "reasonix",
"actorId": "user_1",
"sessionId": "sess_context_read",
"runId": "run_context_read",
"toolCallId": "call_context_read",
"traceId": "trace_context_read",
"args": {
"format": "markdown",
"contextRefs": [{ "kind": "current_page" }],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedRoots": [{ "rootUri": root_uri, "permission": "write" }],
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(status, StatusCode::OK, "{payload}");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["ok"], true);
assert!(
payload["result"]["content"]
.as_str()
.unwrap_or_default()
.contains("mnote.context.read_current_page"),
"{payload}"
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test] #[tokio::test]
async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() { async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
+200 -15
View File
@@ -3791,6 +3791,9 @@ body {
.wolai-page-ai-icon { .wolai-page-ai-icon {
width: 28px; width: 28px;
height: 28px; height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
@@ -3800,6 +3803,13 @@ body {
cursor: pointer; cursor: pointer;
} }
.wolai-page-ai-icon-svg {
width: 16px;
height: 16px;
display: block;
fill: currentColor;
}
.wolai-page-ai-icon:hover, .wolai-page-ai-icon:hover,
.wolai-page-ai-icon.is-active { .wolai-page-ai-icon.is-active {
background: #F3F3F2; background: #F3F3F2;
@@ -3878,7 +3888,8 @@ body {
.wolai-page-ai-profile-select, .wolai-page-ai-profile-select,
.wolai-page-ai-context-select, .wolai-page-ai-context-select,
.wolai-page-ai-skill-search { .wolai-page-ai-skill-search,
.wolai-page-ai-agent-profile {
display: grid; display: grid;
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
@@ -3886,7 +3897,8 @@ body {
.wolai-page-ai-profile-select select, .wolai-page-ai-profile-select select,
.wolai-page-ai-context-select select, .wolai-page-ai-context-select select,
.wolai-page-ai-skill-search input { .wolai-page-ai-skill-search input,
.wolai-page-ai-agent-profile select {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
height: 34px; height: 34px;
@@ -3898,6 +3910,10 @@ body {
font-size: 12px; font-size: 12px;
} }
.wolai-page-ai-agent-profile {
margin-top: 8px;
}
.wolai-page-ai-inline-error { .wolai-page-ai-inline-error {
padding: 8px 10px; padding: 8px 10px;
border: 1px solid rgba(198, 80, 80, 0.18); border: 1px solid rgba(198, 80, 80, 0.18);
@@ -3943,23 +3959,15 @@ body {
line-height: 16px; line-height: 16px;
} }
.wolai-page-ai-session-button { .wolai-page-ai-session-summary {
min-width: 0; min-width: 0;
max-width: 190px; max-width: 190px;
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
border: 0;
background: transparent;
color: #5A5A5A; color: #5A5A5A;
font: inherit; font: inherit;
text-align: left;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
}
.wolai-page-ai-session-button:hover {
color: #1B1C1C;
} }
.wolai-page-ai-settings-head { .wolai-page-ai-settings-head {
@@ -4171,10 +4179,29 @@ body {
} }
.wolai-page-ai-skills-toolbar { .wolai-page-ai-skills-toolbar {
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(124px, 160px) max-content;
align-items: end;
gap: 8px; gap: 8px;
} }
.wolai-page-ai-skill-filter-toggle {
display: inline-flex;
min-height: 34px;
align-items: center;
gap: 6px;
color: #5A5A5A;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.wolai-page-ai-skill-filter-toggle input {
width: 14px;
height: 14px;
margin: 0;
}
.wolai-page-ai-skill-list { .wolai-page-ai-skill-list {
display: flex; display: flex;
min-height: 0; min-height: 0;
@@ -4184,6 +4211,45 @@ body {
overflow: auto; overflow: auto;
} }
.wolai-page-ai-skill-group {
display: grid;
gap: 4px;
}
.wolai-page-ai-skill-group-head {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 2px;
border: 0;
border-radius: 6px;
background: transparent;
color: #8B8782;
font-size: 11px;
line-height: 16px;
cursor: pointer;
}
.wolai-page-ai-skill-group-head:hover {
background: #F7F7F6;
color: #5A5A5A;
}
.wolai-page-ai-skill-group-title {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
}
.wolai-page-ai-skill-group-chevron {
width: 12px;
color: #AAA6A0;
text-align: center;
}
.wolai-page-ai-skill-row { .wolai-page-ai-skill-row {
display: flex; display: flex;
min-height: 38px; min-height: 38px;
@@ -4385,6 +4451,8 @@ button.wolai-page-ai-message-text {
} }
.wolai-page-ai-footer { .wolai-page-ai-footer {
position: relative;
z-index: 20;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
@@ -4412,16 +4480,16 @@ button.wolai-page-ai-message-text {
} }
.wolai-page-ai-composer { .wolai-page-ai-composer {
position: relative;
z-index: 20;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: visible;
border: 1px solid rgba(27, 28, 28, 0.12); border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 10px; border-radius: 10px;
background: #FFF; background: #FFF;
} }
.wolai-page-ai-agent-selector,
.wolai-page-ai-context-refs,
.wolai-page-ai-allowed-roots { .wolai-page-ai-allowed-roots {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -4430,6 +4498,123 @@ button.wolai-page-ai-message-text {
padding: 6px 8px 0; padding: 6px 8px 0;
} }
.wolai-page-ai-agent-picker,
.wolai-page-ai-context-picker {
position: relative;
flex: 0 0 auto;
}
.wolai-page-ai-agent-button,
.wolai-page-ai-context-button {
font-size: 17px;
line-height: 1;
}
.wolai-page-ai-agent-button[aria-expanded="true"],
.wolai-page-ai-context-button[aria-expanded="true"] {
border-color: rgba(27, 28, 28, 0.32);
background: #F7F6F4;
}
.wolai-page-ai-agent-popover,
.wolai-page-ai-context-popover {
position: absolute;
left: 0;
width: min(320px, calc(100vw - 44px));
bottom: calc(100% + 8px);
z-index: 30;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 10px;
background: #FFF;
box-shadow: 0 16px 36px rgba(27, 28, 28, 0.16);
}
.wolai-page-ai-agent-popover[hidden],
.wolai-page-ai-context-popover[hidden] {
display: none !important;
}
.wolai-page-ai-context-popover-head,
.wolai-page-ai-context-popover-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #5A5A5A;
font-size: 12px;
}
.wolai-page-ai-context-option-list {
display: grid;
gap: 8px;
}
.wolai-page-ai-agent-option-list {
display: grid;
gap: 6px;
}
.wolai-page-ai-agent-option {
display: grid;
gap: 2px;
width: 100%;
padding: 8px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
background: #FFF;
color: #1B1C1C;
text-align: left;
cursor: pointer;
}
.wolai-page-ai-agent-option.is-active {
border-color: rgba(27, 28, 28, 0.2);
background: #F7F6F4;
}
.wolai-page-ai-agent-option-label {
font-size: 12px;
font-weight: 600;
}
.wolai-page-ai-agent-option-detail {
color: #8B8782;
font-size: 11px;
}
.wolai-page-ai-context-option {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 8px;
align-items: start;
color: #1B1C1C;
font-size: 12px;
}
.wolai-page-ai-context-option.is-disabled {
color: #AAA6A0;
}
.wolai-page-ai-context-option-main {
display: grid;
gap: 2px;
min-width: 0;
}
.wolai-page-ai-context-option-label {
font-weight: 500;
}
.wolai-page-ai-context-option-detail {
color: #8B8782;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-allowed-roots { .wolai-page-ai-allowed-roots {
padding-top: 4px; padding-top: 4px;
} }
+146 -49
View File
@@ -39,7 +39,15 @@ function debugLog(message) {
const toolContextStorage = new AsyncLocalStorage(); const toolContextStorage = new AsyncLocalStorage();
function isWriteMnoteTool(toolName) { function isWriteMnoteTool(toolName) {
return !['mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch'].includes(toolName); return ![
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.doc.fetch',
'mnote.page.get',
'mnote.block.fetch',
].includes(toolName);
} }
function stableIdPart(value, fallback) { function stableIdPart(value, fallback) {
@@ -60,6 +68,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
idempotencyKey, idempotencyKey,
dryRun, dryRun,
workspaceId, workspaceId,
documentId,
sourceKind,
rootUri,
profile,
capabilityScope,
...args ...args
} = rawArgs || {}; } = rawArgs || {};
const effectiveSessionId = const effectiveSessionId =
@@ -69,6 +82,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`; toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`;
const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`; const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`;
const writeTool = isWriteMnoteTool(toolName); const writeTool = isWriteMnoteTool(toolName);
const capabilities = context.mnoteCapabilities || {};
if (!args.contextRefs && capabilities.contextRefs) args.contextRefs = capabilities.contextRefs;
if (!args.agentId && capabilities.agentId) args.agentId = capabilities.agentId;
if (!args.aiAccessScope && capabilities.aiAccessScope) args.aiAccessScope = capabilities.aiAccessScope;
if (!args.allowedRoots && capabilities.allowedRoots) args.allowedRoots = capabilities.allowedRoots;
const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false; const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false;
const effectiveIdempotencyKey = const effectiveIdempotencyKey =
idempotencyKey || idempotencyKey ||
@@ -76,8 +94,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
return { return {
toolName, toolName,
args, args,
workspaceId: workspaceId || context.workspaceId || 'default', workspaceId: workspaceId || context.workspaceId || capabilities.workspaceId || 'default',
documentId: args.documentId || context.documentId, documentId: documentId || args.documentId || context.documentId || capabilities.documentId,
sourceKind: sourceKind || args.sourceKind || context.sourceKind || capabilities.sourceKind,
rootUri: rootUri || args.rootUri || context.rootUri || capabilities.rootUri,
profile: profile || args.profile || context.profile || capabilities.profile,
actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp', actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
sessionId: effectiveSessionId, sessionId: effectiveSessionId,
runId: effectiveRunId, runId: effectiveRunId,
@@ -85,16 +106,17 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
traceId: effectiveTraceId, traceId: effectiveTraceId,
dryRun: effectiveDryRun, dryRun: effectiveDryRun,
idempotencyKey: effectiveIdempotencyKey, idempotencyKey: effectiveIdempotencyKey,
capabilityScope: capabilityScope || args.capabilityScope || context.capabilityScope || capabilities.capabilityScope,
}; };
} }
if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') { if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
const payload = buildMnoteToolPayload( const payload = buildMnoteToolPayload(
'mnote.doc.markdown_edit', 'mnote.context.read_current_page',
{ {
workspaceId: 'ws_demo', workspaceId: 'ws_demo',
documentId: 'doc_1', documentId: 'doc_1',
operations: [{ search: '旧', replace: '新' }], format: 'markdown',
}, },
{ {
actorId: 'user_1', actorId: 'user_1',
@@ -123,6 +145,44 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) { if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) {
throw new Error('selftest expected identity fields outside args'); throw new Error('selftest expected identity fields outside args');
} }
const contextualPayload = buildMnoteToolPayload(
'mnote.context.read_current_page',
{ format: 'markdown' },
{
actorId: 'user_1',
workspaceId: 'ws_local',
documentId: 'local-md:Current.md',
runId: 'run_local_1',
traceId: 'trace_local_1',
mnoteCapabilities: {
agentId: 'reasonix',
contextRefs: [{ kind: 'current_page' }],
sourceKind: 'local_folder',
rootUri: 'file:///tmp/mnote-local',
profile: 'reasonix',
aiAccessScope: {
permissionLevel: 'read_write',
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'write' }],
allowedResourceIds: ['local-md:Current.md'],
},
},
},
);
if (contextualPayload.sourceKind !== 'local_folder') {
throw new Error('selftest expected sourceKind inherited from mnoteCapabilities');
}
if (contextualPayload.rootUri !== 'file:///tmp/mnote-local') {
throw new Error('selftest expected rootUri inherited from mnoteCapabilities');
}
if (contextualPayload.profile !== 'reasonix') {
throw new Error('selftest expected profile inherited from mnoteCapabilities');
}
if (!Array.isArray(contextualPayload.args.contextRefs) || contextualPayload.args.contextRefs[0]?.kind !== 'current_page') {
throw new Error('selftest expected contextRefs inherited from mnoteCapabilities');
}
if (contextualPayload.args.aiAccessScope?.permissionLevel !== 'read_write') {
throw new Error('selftest expected aiAccessScope inherited from mnoteCapabilities');
}
process.stderr.write('[reasonix-acp-mnote] selftest ok\n'); process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0); process.exit(0);
} }
@@ -328,15 +388,17 @@ function emitUsage(sessionId, used, size) {
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs // Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs
const MNOTE_TOOL_NAMES = [ const MNOTE_TOOL_NAMES = [
'mnote.doc.fetch', 'mnote.skill.read',
'mnote.doc.markdown_edit', 'mnote.context.snapshot',
'mnote.block.*', 'mnote.context.resolve_target',
'mnote.page.*', 'mnote.context.read_current_page',
]; ];
const REASONIX_TOOL_TO_MNOTE_TOOL = { const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_doc_fetch: 'mnote.doc.fetch', mnote_skill_read: 'mnote.skill.read',
mnote_doc_markdown_edit: 'mnote.doc.markdown_edit', mnote_context_snapshot: 'mnote.context.snapshot',
mnote_context_resolve_target: 'mnote.context.resolve_target',
mnote_context_read_current_page: 'mnote.context.read_current_page',
}; };
async function callMnoteTool(toolName, args) { async function callMnoteTool(toolName, args) {
@@ -361,10 +423,50 @@ async function callMnoteTool(toolName, args) {
// ── Register Tools ─────────────────────────────────── // ── Register Tools ───────────────────────────────────
const tools = new ToolRegistry(); const tools = new ToolRegistry();
const chatOnlyTools = new ToolRegistry();
tools.register({ tools.register({
name: 'mnote_doc_fetch', name: 'mnote_skill_read',
description: '读取当前 mnote 文档的 markdown 内容。返回文档标题和正文。', description: '按需读取 MNote skill 正文。只有任务需要 MNote 能力时才调用。',
parameters: {
type: 'object',
properties: {
skillId: { type: 'string', description: 'MNote skill ID' },
agentId: { type: 'string', description: '当前 agent ID' },
},
required: ['skillId'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_skill_read, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_snapshot',
description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。',
parameters: {
type: 'object',
properties: {
contextRefs: { type: 'array', items: {} },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_snapshot, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_resolve_target',
description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。',
parameters: { type: 'object', properties: {} },
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_resolve_target, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_read_current_page',
description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。',
parameters: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -373,33 +475,7 @@ tools.register({
}, },
}, },
readOnly: true, readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_fetch, args), fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_read_current_page, args),
parallelSafe: false,
});
tools.register({
name: 'mnote_doc_markdown_edit',
description: '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。',
parameters: {
type: 'object',
properties: {
documentId: { type: 'string', description: '文档 ID' },
operations: {
type: 'array',
items: {
type: 'object',
properties: {
search: { type: 'string', description: '要搜索的文本片段' },
replace: { type: 'string', description: '替换后的文本' },
},
required: ['search', 'replace'],
},
description: '搜索替换操作列表',
},
},
required: ['documentId', 'operations'],
},
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_markdown_edit, args),
parallelSafe: false, parallelSafe: false,
}); });
@@ -429,20 +505,36 @@ onRequest('session/new', async (params) => {
const sessionId = randomUUID(); const sessionId = randomUUID();
const client = new DeepSeekClient({ apiKey: DEEPSEEK_API_KEY }); const client = new DeepSeekClient({ apiKey: DEEPSEEK_API_KEY });
const systemPrompt = [ const chatSystemPrompt = [
'You are a helpful AI assistant for editing Markdown documents.', 'You are a helpful AI assistant inside MNote.',
'Use mnote_doc_fetch to read the current document.', 'For ordinary chat, reply directly.',
'Use mnote_doc_markdown_edit to apply precise search/replace edits.', 'Do not read or edit MNote pages, files, folders, or attachments unless MNote capabilities are explicitly attached for this prompt.',
'Always use mnote_doc_fetch first to understand the document content before editing.',
].join('\n'); ].join('\n');
const loop = new CacheFirstLoop({ const mnoteSystemPrompt = [
'You are a helpful AI assistant inside MNote.',
'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.',
'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.',
'<available-skills>',
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
'- 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-chat-only — Reply conversationally without MNote file/page tools.',
'</available-skills>',
].join('\n');
const chatLoop = new CacheFirstLoop({
client,
tools: chatOnlyTools,
prefix: new ImmutablePrefix({ system: chatSystemPrompt, toolSpecs: chatOnlyTools.specs() }),
});
const mnoteLoop = new CacheFirstLoop({
client, client,
tools, tools,
prefix: new ImmutablePrefix({ system: systemPrompt, toolSpecs: tools.specs() }), prefix: new ImmutablePrefix({ system: mnoteSystemPrompt, toolSpecs: tools.specs() }),
}); });
sessions.set(sessionId, { id: sessionId, loop, client, aborter: null }); sessions.set(sessionId, { id: sessionId, chatLoop, mnoteLoop, client, aborter: null });
return { sessionId }; return { sessionId };
}); });
@@ -478,7 +570,12 @@ onRequest('session/prompt', async (params) => {
traceId: params.traceId || params.mnoteTraceId, traceId: params.traceId || params.mnoteTraceId,
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
documentId: params.documentId, documentId: params.documentId,
mnoteCapabilities: params.mnoteCapabilities || null,
}; };
const mnoteCapabilities = params.mnoteCapabilities || {};
const useMnoteTools = mnoteCapabilities.attachMnoteCapabilities === true;
const loop = useMnoteTools ? session.mnoteLoop : session.chatLoop;
const promptText = text;
let stopReason = 'end_turn'; let stopReason = 'end_turn';
let hasAssistantOutput = false; let hasAssistantOutput = false;
let hasToolCall = false; let hasToolCall = false;
@@ -509,7 +606,7 @@ onRequest('session/prompt', async (params) => {
try { try {
await toolContextStorage.run(toolContext, async () => { await toolContextStorage.run(toolContext, async () => {
for await (const ev of session.loop.step(text)) { for await (const ev of loop.step(promptText)) {
if (session.aborter?.signal.aborted) { if (session.aborter?.signal.aborted) {
stopReason = 'cancelled'; stopReason = 'cancelled';
break; break;
@@ -148,16 +148,73 @@ async function main() {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
ok: true, ok: true,
active: "reasonix", active: "mnoteai",
profiles: [{ name: "reasonix", label: "Reasonix", modelConfigured: true, apiKeyConfigured: true }], profiles: [
{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true },
{ name: "chemist", label: "Chemist", modelConfigured: true, apiKeyConfigured: true },
],
}), }),
}); });
}); });
await page.route("**/api/hermes/client/skills**", async (route) => { await page.route("**/api/hermes/client/profiles/active", async (route) => {
captured.push({ kind: "profile-active", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }), body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/skills/toggle", async (route) => {
captured.push({ kind: "skill-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/toggle")) {
captured.push({ kind: "skill-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
return;
}
const runtime = url.searchParams.get("runtime");
const profile = url.searchParams.get("profile") || "mnoteai";
const body = runtime === "mnote"
? {
ok: true,
runtime: "mnote",
categories: [{ name: "mnote", skills: [{ id: "mnote-current-page", name: "mnote-current-page", title: "当前页读取", description: "读取当前页", enabled: true, source: "mnote", origin: "builtin" }] }],
archived: [],
}
: runtime === "reasonix"
? {
ok: true,
runtime: "reasonix",
categories: [{ name: "project", skills: [{ id: "reasonix-review", name: "reasonix-review", description: "Reasonix review", enabled: true, toggleable: true, source: "reasonix", origin: "project" }] }],
archived: [],
}
: {
ok: true,
profile,
categories: [{
name: "writing",
skills: [
{ id: "hermes-builtin", name: "hermes-builtin", title: "Hermes builtin", description: `Builtin ${profile}`, enabled: true, source: "builtin", origin: "builtin" },
{ id: profile === "chemist" ? "hermes-chemist" : "hermes-writer", name: profile === "chemist" ? "hermes-chemist" : "hermes-writer", title: profile === "chemist" ? "Hermes chemist" : "Hermes writer", description: `Hermes ${profile}`, enabled: profile !== "chemist", source: "local", origin: "installed" },
],
}],
archived: [],
};
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}); });
}); });
await page.route("**/api/hermes/client/sessions", async (route) => { await page.route("**/api/hermes/client/sessions", async (route) => {
@@ -190,12 +247,40 @@ async function main() {
}); });
}); });
await page.route("**/api/hermes/client/events/*", async (route) => { await page.route("**/api/hermes/client/events/*", async (route) => {
const includeReceipt = captured.filter((item) => item.kind === "run").length > 1;
const completed = { event: "run.completed", run_id: `run_task502_${suffix}`, output: "Task502 response" };
if (includeReceipt) {
completed.agentAudit = {
rootUri,
actorId: "mnote-e2e",
actorType: "user",
agentKind: "reasonix",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task502 receipt" }],
agentRunReceipt: {
schema: "mnote.agent_run_receipt.v1",
runId: `run_task502_${suffix}`,
sessionId: `mnote_task502_${suffix}`,
workspaceId,
documentId,
rootUri,
agentKind: "reasonix",
status: "completed",
permission: "write",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task502 receipt" }],
refresh: {
touchesCurrentFile: true,
currentDocumentId: documentId,
strategy: "refresh_current_file",
},
},
};
}
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" }, headers: { "content-type": "text/event-stream; charset=utf-8" },
body: body:
`data: ${JSON.stringify({ event: "message.delta", run_id: `run_task502_${suffix}`, delta: "Task502 response" })}\n\n` + `data: ${JSON.stringify({ event: "message.delta", run_id: `run_task502_${suffix}`, delta: "Task502 response" })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: `run_task502_${suffix}`, output: "Task502 response" })}\n\n`, `data: ${JSON.stringify(completed)}\n\n`,
}); });
}); });
@@ -219,11 +304,34 @@ async function main() {
assert(!defaultChatText.includes("gateway:"), "默认聊天面不应显示 gateway 技术详情"); assert(!defaultChatText.includes("gateway:"), "默认聊天面不应显示 gateway 技术详情");
assert(!defaultChatText.includes("Hermes profile"), "默认聊天面不应显示 Hermes profile 技术项"); assert(!defaultChatText.includes("Hermes profile"), "默认聊天面不应显示 Hermes profile 技术项");
await page.locator("[data-page-ai-agent-selector]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); assert.strictEqual(
await page.locator("[data-page-ai-agent-selector]").count(),
0,
"默认输入区不应继续平铺 agent selector,应收敛为一个 agent 按钮",
);
const agentButton = page.locator("[data-page-ai-agent-button]");
await agentButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await agentButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"agent 按钮应显示在输入区下方工具栏中",
);
const agentButtonLabel = await agentButton.getAttribute("aria-label");
assert(agentButtonLabel.includes("Agent"), `agent 按钮应提供当前 agent 摘要: ${agentButtonLabel}`);
await agentButton.click({ timeout: UI_TIMEOUT_MS });
const agentPopover = page.locator("[data-page-ai-agent-popover]");
await agentPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const agentIds = await page.$$eval("[data-page-ai-agent-id]", (nodes) => const agentIds = await page.$$eval("[data-page-ai-agent-id]", (nodes) =>
nodes.map((node) => node.getAttribute("data-page-ai-agent-id")).filter(Boolean), nodes.map((node) => node.getAttribute("data-page-ai-agent-id")).filter(Boolean),
); );
assert.deepStrictEqual(agentIds.sort(), ["chat_only", "hermes", "reasonix"]); assert.deepStrictEqual(agentIds.sort(), ["chat_only", "hermes", "reasonix"]);
await page.locator('[data-page-ai-agent-id="hermes"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-popover] [data-page-ai-profile-select]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-popover] [data-page-ai-profile-select]').selectOption("chemist", { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-profile") === "chemist",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-agent-id="reasonix"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-agent-id="reasonix"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction( await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "reasonix", () => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "reasonix",
@@ -232,15 +340,120 @@ async function main() {
); );
const contextRefs = page.locator("[data-page-ai-context-refs]"); const contextRefs = page.locator("[data-page-ai-context-refs]");
await contextRefs.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); assert.strictEqual(
await contextRefs.count(),
0,
"默认输入区不应继续平铺 contextRef chip,应收敛为一个上下文按钮",
);
const contextButton = page.locator("[data-page-ai-context-button]");
await contextButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert( assert(
await page.locator('[data-page-ai-context-ref="current_page"][aria-pressed="true"]').isVisible(), await contextButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"当前页 contextRef 应默认勾选", "上下文按钮应显示在输入区下方工具栏中",
);
assert.strictEqual(
await page.locator('.wolai-page-ai-composer-bar [data-page-ai-action="history"]').count(),
0,
"历史会话不应继续占用输入区下方工具栏位置",
); );
assert( assert(
await page.locator("[data-page-ai-allowed-root]").first().isVisible(), await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').isVisible(),
"SQLite 授权区域应作为 allowedRoot chip 显示", "历史会话入口应移动到右上角设置旁边",
); );
assert(
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').isVisible(),
"技能入口应显示在右上角历史按钮左侧",
);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="skills"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillPanelText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(skillPanelText.includes("MNote 内置技能"), "技能面板应展示 MNote 内置技能分组");
assert(skillPanelText.includes("Reasonix 技能"), "技能面板应展示 Reasonix 技能分组");
assert(skillPanelText.includes("Hermes 技能"), "技能面板应展示 Hermes 技能分组");
assert(skillPanelText.includes("Hermes chemist"), "Hermes 技能应随 chemist profile 加载");
assert(!skillPanelText.includes("Hermes writer"), "Hermes profile 切到 chemist 后不应继续显示上一 profile 技能");
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').count(),
0,
"MNote 技能分组折叠后不应显示组内技能",
);
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-hide-hermes-builtin]').check({ timeout: UI_TIMEOUT_MS });
const hiddenBuiltinText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(!hiddenBuiltinText.includes("Hermes builtin"), "隐藏 Hermes 内置后不应显示 Hermes 内置技能");
await page.locator('[data-page-ai-hide-hermes-builtin]').uncheck({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-profile-select]').selectOption("mnoteai", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').count(),
0,
"Hermes profile 切回 mnoteai 后不应残留 chemist 技能",
);
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-profile-select]').selectOption("chemist", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillsScreenshot = await saveScreenshot(page, "00-skills-panel");
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "hermes-chemist",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const contextButtonText = await contextButton.innerText({ timeout: UI_TIMEOUT_MS });
assert(
contextButtonText.trim() === "⇅",
`上下文按钮应显示为单个上下文图标: ${contextButtonText}`,
);
const contextButtonLabel = await contextButton.getAttribute("aria-label");
assert(
contextButtonLabel.includes("当前页") && contextButtonLabel.includes("打开资源"),
`上下文按钮 aria-label 应摘要展示已选上下文: ${contextButtonLabel}`,
);
await contextButton.click({ timeout: UI_TIMEOUT_MS });
const contextPopover = page.locator("[data-page-ai-context-popover]");
await contextPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="current_page"]:checked').isVisible(),
"当前页 contextRef 应在 popover checkbox 中默认勾选",
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator(".wolai-page-ai-composer > [data-page-ai-allowed-roots]").count(),
0,
"授权区域不应继续在输入区显示黑色 chip,应收敛到上下文 popover 内",
);
await contextButton.click({ timeout: UI_TIMEOUT_MS });
assert(
(await contextPopover.innerText({ timeout: UI_TIMEOUT_MS })).includes("授权区域"),
"SQLite 授权区域应在上下文 popover 内展示",
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("收到请回复收到", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
assert.strictEqual(
await page.locator("[data-page-ai-tool-card]").count(),
0,
"纯聊天短请求不应显示 MNote tool card",
);
const ackRuns = captured.filter((item) => item.kind === "run");
assert(ackRuns.length >= 1, "未捕获纯聊天 Page AI run payload");
const ackRunBody = JSON.parse(ackRuns[ackRuns.length - 1].body);
assert.strictEqual(ackRunBody.pageContext?.aiContext?.pageText, undefined, "Page AI run 不应默认上传 pageText");
assert.strictEqual(ackRunBody.pageContext?.aiContext?.pageXml, undefined, "Page AI run 不应默认上传 pageXml");
assert.strictEqual(ackRunBody.pageContext?.aiContext?.contextBlocks, undefined, "Page AI run 不应默认上传 contextBlocks");
assert.strictEqual(ackRunBody.pageContext?.evidence, undefined, "Page AI run 不应默认上传选区 evidence 正文");
assert.strictEqual(ackRunBody.selectedText, "", "Page AI run 不应默认上传 selectedText 正文");
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -272,11 +485,24 @@ async function main() {
await page.locator('[data-page-ai-panel="chat-only-settings"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-panel="chat-only-settings"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="current_page"]').click({ timeout: UI_TIMEOUT_MS }); await contextButton.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="folder"]').click({ timeout: UI_TIMEOUT_MS }); await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="current_page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="changed_files"]').click({ timeout: UI_TIMEOUT_MS }); await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="folder"]').click({ timeout: UI_TIMEOUT_MS });
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="changed_files"]').click({ timeout: UI_TIMEOUT_MS });
const updatedContextButtonLabel = await contextButton.getAttribute("aria-label");
assert(
updatedContextButtonLabel.includes("打开资源") && updatedContextButtonLabel.includes("文件夹"),
`勾选变化后上下文按钮摘要应更新: ${updatedContextButtonLabel}`,
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
const runsBeforeDocumentTask = captured.filter((item) => item.kind === "run").length;
await page.locator("[data-page-ai-input]").fill(`Task502 agent/context ${suffix}`, { timeout: UI_TIMEOUT_MS }); await page.locator("[data-page-ai-input]").fill(`Task502 agent/context ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
const runWaitStarted = Date.now();
while (captured.filter((item) => item.kind === "run").length <= runsBeforeDocumentTask) {
assert(Date.now() - runWaitStarted < UI_TIMEOUT_MS, "未捕获第二次 Page AI run payload");
await page.waitForTimeout(50);
}
await page.waitForFunction( await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"), () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null, null,
@@ -289,12 +515,25 @@ async function main() {
assert.strictEqual(runBody.agentId, "reasonix"); assert.strictEqual(runBody.agentId, "reasonix");
assert(Array.isArray(runBody.contextRefs), "run payload 必须包含 contextRefs 数组"); assert(Array.isArray(runBody.contextRefs), "run payload 必须包含 contextRefs 数组");
assert(!runBody.contextRefs.some((item) => item.kind === "current_page"), "取消当前页后不应发送 current_page contextRef"); assert(!runBody.contextRefs.some((item) => item.kind === "current_page"), "取消当前页后不应发送 current_page contextRef");
assert.strictEqual(runBody.pageContext?.aiContext?.pageText, undefined, "取消当前页后不应发送 pageText"); assert.strictEqual(runBody.pageContext?.aiContext?.pageText, undefined, "Page AI run 不应默认上传 pageText");
assert.strictEqual(runBody.pageContext?.aiContext?.pageXml, undefined, "取消当前页后不应发送 pageXml"); assert.strictEqual(runBody.pageContext?.aiContext?.pageXml, undefined, "Page AI run 不应默认上传 pageXml");
assert.strictEqual(runBody.pageContext?.aiContext?.contextBlocks, undefined, "取消当前页后不应发送 contextBlocks"); assert.strictEqual(runBody.pageContext?.aiContext?.contextBlocks, undefined, "Page AI run 不应默认上传 contextBlocks");
assert.strictEqual(runBody.pageContext?.evidence, undefined, "Page AI run 不应默认上传选区 evidence 正文");
assert.strictEqual(runBody.selectedText, "", "Page AI run 不应默认上传 selectedText 正文");
assert(runBody.contextRefs.some((item) => item.kind === "active_editor" && item.documentId === documentId)); assert(runBody.contextRefs.some((item) => item.kind === "active_editor" && item.documentId === documentId));
assert(runBody.contextRefs.some((item) => item.kind === "folder" && item.rootUri === rootUri)); assert(runBody.contextRefs.some((item) => item.kind === "folder" && item.rootUri === rootUri));
assert(runBody.contextRefs.some((item) => item.kind === "changed_files")); assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-current-page"], false, "MNote skill 开关应进入 run payload");
assert.strictEqual(runBody.skillPreferences?.reasonix?.["reasonix-review"], false, "Reasonix skill 开关应进入 run payload");
const preferenceBodies = captured
.filter((item) => item.kind === "ui-preferences" && item.method === "PUT")
.map((item) => JSON.parse(item.body || "{}"));
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile_id"] === "chemist"), "Hermes profile 选择应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.mnote.enabled"]?.["mnote-current-page"] === false), "MNote skill 开关应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.reasonix.skills.enabled"]?.["reasonix-review"] === false), "Reasonix skill 开关应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.groups.collapsed"]?.mnote === true), "技能分组折叠状态应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile.chemist.skills.hide_builtin"] === true), "Hermes 隐藏内置技能应按 profile 写入 SQLite UI preference");
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").profile === "chemist"), "Hermes skill 开关应按 profile 调用");
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组"); assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
assert(runBody.allowedRoots.some((item) => assert(runBody.allowedRoots.some((item) =>
item.rootUri === rootUri item.rootUri === rootUri
@@ -302,6 +541,16 @@ async function main() {
&& item.source === "sqlite_directory_grant" && item.source === "sqlite_directory_grant"
)); ));
assert.strictEqual(runBody.runTargetSnapshot?.source, "open_editors_snapshot"); assert.strictEqual(runBody.runTargetSnapshot?.source, "open_editors_snapshot");
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-receipt-current-refresh") === "true",
null,
{ timeout: UI_TIMEOUT_MS },
);
assert.strictEqual(
await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-receipt-filetree-refresh")),
"true",
"agentRunReceipt.changedFiles 应触发文件树事件驱动刷新",
);
const screenshot = await saveScreenshot(page, "01-agent-selector-context"); const screenshot = await saveScreenshot(page, "01-agent-selector-context");
const result = { const result = {
@@ -312,6 +561,7 @@ async function main() {
rootUri, rootUri,
documentId, documentId,
screenshot, screenshot,
skillsScreenshot,
captured, captured,
}; };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
+9
View File
@@ -0,0 +1,9 @@
# MNote chat only
Use this skill when the user is only chatting.
Rules:
- Reply directly to the user.
- Do not read the current page.
- Do not call MNote document, page, file, or workspace tools.
- Do not mention hidden runtime details unless the user asks.
+10
View File
@@ -0,0 +1,10 @@
# MNote current page
Use this skill only when the user asks about the current MNote page or explicitly says to use the current page.
Rules:
- Do not read the current page for greetings, acknowledgements, or generic chat.
- First call `mnote.context.snapshot` or `mnote.context.resolve_target` when you need target metadata.
- Call `mnote.context.read_current_page` only when page content is actually needed.
- Treat the returned Markdown as local-first page truth for this run.
- If the page is dirty or in conflict, report that instead of silently overwriting it.
+12
View File
@@ -0,0 +1,12 @@
# MNote local file editing
Use this skill only when the user asks to read, edit, summarize, search, or patch files in the current MNote workspace.
Rules:
- Stay inside allowed roots returned by MNote.
- Use `mnote.context.snapshot` or `mnote.context.resolve_target` to locate the workspace root, current document, and allowed context.
- Prefer the agent runtime's own local file read/edit capability for Markdown files inside allowed roots.
- Before writing, resolve the target and read the relevant file content with the agent's native file tools.
- After writing, read the file back with the agent's native file tools and report changed files.
- Do not use MNote file tools for pure chat.
- If authorization is missing, ask the user to grant access instead of guessing a path.