feat(page-ai): add skill context and agent profile policy
This commit is contained in:
@@ -0,0 +1,456 @@
|
|||||||
|
# 7-40 Page AI MNote skill library and context tool contract v1
|
||||||
|
|
||||||
|
> 创建时间:2026-05-29
|
||||||
|
>
|
||||||
|
> 状态:`PROCESS`
|
||||||
|
>
|
||||||
|
> Owner:Page AI skill/tool capability surface + MNote host-side context provider
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `design/07-ai/done/7-39-page-ai-agent-selector-context-authorization-settings-v1.md`
|
||||||
|
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
|
||||||
|
> - `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md`
|
||||||
|
> - `design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
|
||||||
|
> - `design/07-ai/reference/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
|
||||||
|
>
|
||||||
|
> 参考代码:
|
||||||
|
> - `reference-code/PilotDeck/src/context/prompt/PromptAssembler.ts`
|
||||||
|
> - `reference-code/PilotDeck/src/context/extension/ExtensionResolver.ts`
|
||||||
|
> - `reference-code/PilotDeck/src/tool/builtin/readSkill.ts`
|
||||||
|
> - `reference-code/PilotDeck/src/context/input/InputProcessor.ts`
|
||||||
|
>
|
||||||
|
> 关键口径:Hermes 和 Reasonix 本身已经是完整 agent,拥有自己的记忆、会话隔离、agent loop、工具调度和运行时状态。MNote 不建设第三套 agent runtime,不把 MNote 上下文、工具说明或页面正文强行注入每条用户 prompt。MNote 要提供一个专用 skill library 和结构化 context tools,让 agent 在需要时自行选择读取。
|
||||||
|
|
||||||
|
## 1. 背景与问题
|
||||||
|
|
||||||
|
`7-39` 已经完成第一轮 Page AI UI 收口:
|
||||||
|
|
||||||
|
- `sidebar-page-ai-runtime.js` 已有 `PAGE_AI_AGENT_REGISTRY`。
|
||||||
|
- `PAGE_AI_CONTEXT_REF_REGISTRY` 已包含 `current_page / selection / active_editor / file / folder / changed_files`。
|
||||||
|
- 发送 `/api/hermes/client/runs` 时 payload 已包含 `agentId`、`contextRefs`、`allowedRoots`、`editorTarget`、`runTargetSnapshot`。
|
||||||
|
- SQLite directory grants 和 user preferences 已作为授权与偏好控制面。
|
||||||
|
|
||||||
|
但当前暴露出一个更关键的系统问题:
|
||||||
|
|
||||||
|
1. 简单聊天请求,例如“收到请回复收到”,仍可能触发大量 `mnote_doc_fetch` 等工具调用。
|
||||||
|
2. 当前实现倾向把 MNote page context、tool guidance、runTargetSnapshot 等内容拼成大段 instructions 注入上游 prompt。
|
||||||
|
3. 这种方式会诱导 agent 把所有请求都理解成 MNote 文档任务,也会增加 token、工具误调用和失败噪音。
|
||||||
|
4. UI 上 contextRefs 与授权区域已经可勾选,但后台语义仍混杂:勾选范围被当成“必须塞给模型的上下文”,而不是“允许 agent 按需读取的能力边界”。
|
||||||
|
|
||||||
|
因此本稿替代原先以 `AgentRunEnvelope` 为中心的方案。`AgentRunEnvelope` 仍保留为 audit / permission / receipt 的宿主侧结构,但不再是默认 prompt 注入中心。
|
||||||
|
|
||||||
|
## 2. PilotDeck 对照结论
|
||||||
|
|
||||||
|
### 2.1 可借鉴模型
|
||||||
|
|
||||||
|
PilotDeck 的 `PromptAssembler` 采用分层上下文:
|
||||||
|
|
||||||
|
- `ExtensionResolver.listSkills()` 只返回 skill 名称、描述和 namespace。
|
||||||
|
- `formatSkills()` 只生成 `<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 tools;Hermes / Reasonix 是否暴露 MNote tools 由用户勾选的 `contextRefs` 与授权边界决定,普通消息是否调用工具由 agent 自己判断。
|
||||||
|
7. UI 继续按 7-39 的 Cline 式模型收敛:输入区只保留 `+ / Agent / 上下文 / 停止 / 发送`,不显示 `write · /path` 黑色授权提示。
|
||||||
|
|
||||||
|
## 4. 产品设计:Page AI 输入区
|
||||||
|
|
||||||
|
### 4.1 默认输入区
|
||||||
|
|
||||||
|
Page AI 输入区默认只显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[+] [Agent] [上下文] [停止] [发送]
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- `Agent` 是按钮 + popover,不在输入区平铺 Hermes / Reasonix / Chat-only chip。
|
||||||
|
- `上下文` 是按钮 + popover,不在输入区平铺 6 个 context chip。
|
||||||
|
- 授权区域只在上下文 popover 中展示,不再在输入区显示 `write · /mnt/Data1T/mnote` 黑色提示。
|
||||||
|
- 输入区不显示 profile、model、gateway、runtime、tool trace 等技术噪音。
|
||||||
|
|
||||||
|
### 4.2 Agent popover
|
||||||
|
|
||||||
|
Agent popover 显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
选择 Agent
|
||||||
|
|
||||||
|
(x) Reasonix
|
||||||
|
适合工作区任务和文件编辑
|
||||||
|
|
||||||
|
( ) Hermes
|
||||||
|
适合 MNote 内置工具和页面操作
|
||||||
|
|
||||||
|
( ) Chat-only
|
||||||
|
只聊天,不申请文件读写能力
|
||||||
|
```
|
||||||
|
|
||||||
|
选择 agent 后:
|
||||||
|
|
||||||
|
- payload 显式携带 `agentId`。
|
||||||
|
- `chat_only` 强制不挂载 MNote context tools。
|
||||||
|
- Hermes / Reasonix 可挂载 skill/tool capability,但是否读取由 agent 决定。
|
||||||
|
|
||||||
|
### 4.3 Context popover
|
||||||
|
|
||||||
|
Context popover 显示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
发送给 AI 的上下文权限
|
||||||
|
|
||||||
|
[x] 当前页
|
||||||
|
允许 agent 按需读取当前 Markdown 页面
|
||||||
|
|
||||||
|
[ ] 选区
|
||||||
|
当前没有选区
|
||||||
|
|
||||||
|
[x] 打开资源
|
||||||
|
允许 agent 按需读取当前打开资源
|
||||||
|
|
||||||
|
[ ] 文件
|
||||||
|
允许 agent 按需读取当前文件
|
||||||
|
|
||||||
|
[ ] 文件夹
|
||||||
|
允许 agent 在授权根内按需读取文件夹
|
||||||
|
|
||||||
|
[ ] 最近修改
|
||||||
|
允许 agent 读取上次 run 的 changed files 摘要
|
||||||
|
|
||||||
|
授权区域
|
||||||
|
write · /mnt/Data1T/mnote
|
||||||
|
|
||||||
|
[恢复默认] [完成]
|
||||||
|
```
|
||||||
|
|
||||||
|
语义:
|
||||||
|
|
||||||
|
- 勾选项表达“允许 agent 读取的上下文范围”,不是“强制把内容发给模型”。
|
||||||
|
- `current_page` 勾选后,只代表工具可读取当前页;页面正文不默认塞进 prompt。
|
||||||
|
- `selection` 勾选且有选区时,agent 可通过 tool 读取选区文本;选区文本不默认塞进 prompt,除非后续用户明确选择“直接附加选区文本”模式。
|
||||||
|
- `folder` 必须由服务端 SQLite grants 重算 allowed roots。
|
||||||
|
|
||||||
|
## 5. MNote Skill Library 合同
|
||||||
|
|
||||||
|
### 5.1 Skill registry
|
||||||
|
|
||||||
|
新增 MNote 专用 skill registry,第一阶段可以用静态文件或 Rust 静态 registry,后续可落 SQLite / filesystem。
|
||||||
|
|
||||||
|
推荐初始技能:
|
||||||
|
|
||||||
|
| skill id | 简述 | 默认 agent |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `mnote-current-page` | 如何读取当前页、判断 dirty/conflict、刷新页面 | Hermes / Reasonix |
|
||||||
|
| `mnote-selection` | 如何读取和使用选区 | Hermes / Reasonix |
|
||||||
|
| `mnote-local-file` | 如何解析 MNote target / allowed roots,并让 agent 使用自身文件能力读写本地 Markdown | Reasonix |
|
||||||
|
| `mnote-attachments` | 如何处理 Markdown 标准链接、图片、PDF/Office 附件 | Hermes / Reasonix |
|
||||||
|
| `mnote-workspace-search` | 如何在授权 workspace 内搜索文件和引用 | Reasonix |
|
||||||
|
| `mnote-agent-receipt` | agent 写入后如何报告 changed files 与刷新需求 | Hermes / Reasonix |
|
||||||
|
| `mnote-chat-only` | 只聊天时不得调用 MNote 文件/页面工具 | Chat-only |
|
||||||
|
|
||||||
|
Skill 元数据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "mnote-local-file",
|
||||||
|
"title": "MNote local file editing",
|
||||||
|
"description": "Resolve MNote targets and allowed roots before using the agent runtime's native local file tools.",
|
||||||
|
"agentIds": ["reasonix", "hermes"],
|
||||||
|
"readOnly": false,
|
||||||
|
"requiresContextRefs": ["current_page", "file", "folder"],
|
||||||
|
"toolNames": ["mnote.context.snapshot", "mnote.context.resolve_target"],
|
||||||
|
"path": "skills/mnote-local-file/SKILL.md"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Prompt 中只出现摘要
|
||||||
|
|
||||||
|
Page AI 上游 prompt 中最多允许出现类似摘要:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<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 B:Skill registry 设计与最小实现
|
||||||
|
|
||||||
|
- [x] 新增 MNote skill metadata registry。证据:`rust/crates/mnote-web/src/hermes_tools/skill.rs`。
|
||||||
|
- [x] 新增 `mnote_skill.read` 或等价工具。证据:`mnote.skill.read` manifest + route dispatch。
|
||||||
|
- [x] 添加 `mnote-current-page`、`mnote-local-file`、`mnote-chat-only` 三个最小 skill 正文。证据:`skills/mnote-*/SKILL.md`。
|
||||||
|
- [x] 单测覆盖 skill id lookup、未知 skill、agentId 过滤。证据:`cargo test --manifest-path rust/Cargo.toml -p mnote-web skill_lookup -- --nocapture`。
|
||||||
|
|
||||||
|
### Batch C:Context tools 合同
|
||||||
|
|
||||||
|
- [x] 新增或收口 `mnote_context.snapshot`。证据:`mnote.context.snapshot` manifest + `context_tools::context_snapshot`。
|
||||||
|
- [x] 新增或收口 `mnote_context.read_current_page`。证据:`mnote.context.read_current_page` manifest + current_page contextRef guard。
|
||||||
|
- [x] 新增或收口 `mnote_context.resolve_target`。证据:`mnote.context.resolve_target` manifest + route dispatch。
|
||||||
|
- [x] 工具返回结构化 JSON,不把正文混入 prompt。证据:context snapshot / resolve target 只返回 JSON;读取正文必须显式调用 read_current_page。
|
||||||
|
- [x] Rust 定点测试覆盖 contextRefs 权限。证据:`cargo test --manifest-path rust/Cargo.toml -p mnote-web context_ref_enabled -- --nocapture`。
|
||||||
|
|
||||||
|
### Batch D:Capability attach policy
|
||||||
|
|
||||||
|
- [x] 实现 `PageAiCapabilityPolicy` 或等价函数。证据:`page_ai_capability_policy`。
|
||||||
|
- [x] `chat_only` 永不挂载 MNote 文件/页面工具。证据:policy 单测 + Reasonix wrapper chatLoop 使用空 ToolRegistry。
|
||||||
|
- [x] MNote 不按“收到/你好/总结当前页”等 prompt 文本强行分类能力;普通消息是否调用工具由 agent 自己判断。证据:`page_ai_capability_policy_exposes_selected_context_without_prompt_classifying`、`capability_policy_does_not_text_classify_ack_prompt`。
|
||||||
|
- [x] 只在 Hermes / Reasonix 且用户勾选 MNote contextRefs 时暴露 skill/tool capability;没有 contextRefs 或 Chat-only 时不挂载。证据:`page_ai_capability_policy_does_not_attach_without_context_refs`、`page_ai_capability_policy_never_attaches_for_chat_only`。
|
||||||
|
- [x] 单测覆盖 selected contextRefs、无 contextRefs、Chat-only、ack prompt 不文本分类四类输入。证据:`cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_capability_policy -- --nocapture`。
|
||||||
|
|
||||||
|
### Batch E:ACP prompt 收口
|
||||||
|
|
||||||
|
- [x] ACP `prompt_blocks` 只包含用户原文和用户显式附件,不包含 MNote tool guidance。证据:`acp_stream_events` 只传 `input.to_string()`。
|
||||||
|
- [x] skill 摘要如果需要进入 system/context,只作为短 `<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 F:UI 收口
|
||||||
|
|
||||||
|
- [x] Agent selector 改为 `+` 旁边的 Agent 按钮 + popover。证据:`task502-page-ai-agent-selector-context-smoke` 通过。
|
||||||
|
- [x] Context refs 改为 `+` 旁边的上下文按钮 + popover。证据:`task502-page-ai-agent-selector-context-smoke` 通过。
|
||||||
|
- [x] 输入区不显示 `write · /path` 黑色授权提示。证据:截图 `tmp/task502-page-ai-agent-selector-context-smoke/01-agent-selector-context.png`。
|
||||||
|
- [x] 默认聊天面不显示 profile/model/gateway/runtime/tool trace。证据:`task502-page-ai-agent-selector-context-smoke` 断言通过。
|
||||||
|
- [x] 浏览器截图验证 popover、选中态、输入区清爽度。证据:`tmp/task502-page-ai-agent-selector-context-smoke/01-agent-selector-context.png`。
|
||||||
|
|
||||||
|
### Batch G:Tool card 降噪
|
||||||
|
|
||||||
|
- [x] 普通聊天不显示 MNote tool card。证据:`task502-page-ai-agent-selector-context-smoke` 对“收到请回复收到”断言无 tool card。
|
||||||
|
- [x] Page AI run payload 不默认上传 `pageText/pageXml/contextBlocks/selectedText/evidence` 正文;当前页/选区正文必须由 agent 通过 context tools 按需读取。证据:`pageAiPageContextForRefs()` 默认裁剪正文,`task502-page-ai-agent-selector-context-smoke` 断言通过。
|
||||||
|
- [x] tool 调用失败时卡片默认折叠,显示简短错误和展开入口。证据:tool message 使用 `<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 H:Receipt 与事件驱动刷新
|
||||||
|
|
||||||
|
- [x] 保留 `AgentRunReceipt` / `agentAudit.changedFiles`。证据:`local_agent_audit_event` 写入 `agentRunReceipt`,`cargo test --manifest-path rust/Cargo.toml -p mnote-web local_agent_audit_event_carries_agent_run_receipt -- --nocapture`。
|
||||||
|
- [x] clean buffer agent 写入后事件驱动刷新当前页。证据:前端优先消费 `agentRunReceipt.refresh.touchesCurrentFile` 并派发 `mnote:page-ai-tool-write-completed`,`document-session-runtime.js` 负责 clean refresh / dirty conflict。
|
||||||
|
- [x] dirty buffer agent 写入后进入 conflict。证据:`document-session-runtime.js` 的 `refreshDocumentSessionsFromExternalWrite` 对 dirty / saving / recent input 标记 `external-change-conflict`,事件来源改为 receipt。
|
||||||
|
- [x] 不引入定时轮询。证据:本轮只使用 ACP SSE `run.completed`、浏览器 CustomEvent、现有 watcher/WS/SSE 事件,没有新增 `setInterval` 或轮询。
|
||||||
|
- [x] changed files 与文件树/页面树刷新走 watcher、ACP event、WS/SSE 或现有事件。证据:`agentRunReceipt.changedFiles` 转为 `tree:local-folder-watch-batch`,由 `sidebar-tree-live-apply-runtime.js` 事件驱动刷新 affected parents。
|
||||||
|
|
||||||
|
### Batch I:验证矩阵与归档
|
||||||
|
|
||||||
|
- [x] Rust targeted tests:skill registry、capability policy、context tool auth。证据:`page_ai_capability_policy`、`skill_lookup`、`context_ref_enabled` 定点测试通过。
|
||||||
|
- [x] JS `node --check`:Page AI runtime 与 smoke。证据:`node --check scripts/reasonix-acp-wrapper.mjs rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js rust/crates/mnote-web/browser/sidebar-tree-runtime.js scripts/task502-page-ai-agent-selector-context-smoke.js`。
|
||||||
|
- [x] 浏览器 smoke:Agent popover、Context popover、纯聊天无工具调用、payload 不默认上传正文、receipt 事件驱动刷新。证据:`node scripts/task502-page-ai-agent-selector-context-smoke.js`。
|
||||||
|
- [x] 浏览器截图:输入区不显示授权黑 chip。证据:`tmp/task502-page-ai-agent-selector-context-smoke/01-agent-selector-context.png`。
|
||||||
|
- [x] `git diff --check`。
|
||||||
|
- [x] `codegraph sync .`,必要时 `codegraph index . --force`。
|
||||||
|
- [x] checklist 全部完成后移动到 `design/07-ai/done/`。
|
||||||
|
|
||||||
|
## 12. 归档条件
|
||||||
|
|
||||||
|
满足以下条件后,本稿可移动到 `design/07-ai/done/`:
|
||||||
|
|
||||||
|
- Page AI 已采用 MNote skill library + context tools 模型。
|
||||||
|
- ACP 用户 prompt 保持干净,不再默认注入 MNote instructions。
|
||||||
|
- 纯聊天请求不会触发 MNote doc/page/file 工具。
|
||||||
|
- 文档任务仍能通过 skill/tool 按需读取当前页或文件。
|
||||||
|
- Agent / Context UI 已收敛为输入区按钮 + popover。
|
||||||
|
- 真实浏览器截图与 smoke 证明默认聊天面没有冗余技术噪音。
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
# 7-41 Page AI Hermes / Reasonix user profile isolation v2
|
||||||
|
|
||||||
|
> 创建时间:2026-05-29
|
||||||
|
>
|
||||||
|
> 状态:`process`
|
||||||
|
>
|
||||||
|
> Owner:Page AI agent identity / Hermes profile policy / Reasonix memory policy
|
||||||
|
>
|
||||||
|
> 上位依据:
|
||||||
|
> - `design/07-ai/done/7-39-page-ai-agent-selector-context-authorization-settings-v1.md`
|
||||||
|
> - `design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md`
|
||||||
|
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
|
||||||
|
> - `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md`
|
||||||
|
>
|
||||||
|
> 参考依据:
|
||||||
|
> - Hermes 官方 profile 文档:profile 是独立 Hermes home,包含 `config.yaml`、`.env`、`SOUL.md`、memories、sessions、skills、cron、state database、gateway state。
|
||||||
|
> - Hermes WebUI:技能 toggle 直接写 profile `config.skills.disabled`。
|
||||||
|
> - Hermes VSCode:只管理会话和一次性上下文建议,不提供真正的 per-user skill disable。
|
||||||
|
> - PilotDeck:以 WorkSpace 为边界隔离文件、记忆和技能,并强调白盒记忆可追溯。
|
||||||
|
> - Reasonix 文档/本地实现:默认使用 `~/.reasonix/memory/global` 与 `~/.reasonix/memory/<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。
|
||||||
|
- [ ] 更新 smoke:agent 切换、MNote 内置 skill per-user toggle、Hermes profile 切换、shared readonly、personal skill toggle、Reasonix memory off/on。
|
||||||
|
- [ ] 运行 `node --check` 覆盖相关 browser runtime / smoke。
|
||||||
|
- [ ] 运行 Rust 定点测试覆盖 SQLite profile policy 与 Hermes skill toggle 权限。
|
||||||
|
- [ ] 运行真实浏览器验证并截图。
|
||||||
|
- [ ] 运行 `git diff --check`。
|
||||||
|
- [ ] 涉及代码图后运行 `codegraph sync .`。
|
||||||
|
- [ ] 完成后将本 checklist 移动到 `done/` 或标记为 `done`。
|
||||||
|
|
||||||
|
## 8. 验收口径
|
||||||
|
|
||||||
|
完成后必须满足:
|
||||||
|
|
||||||
|
- 普通用户可以启停自己的 MNote 内置 skills,开关保存到 SQLite 并由服务端 tool policy 执行。
|
||||||
|
- 用户 A 的 MNote 内置 skill 开关不影响用户 B。
|
||||||
|
- 普通用户无法修改 shared `lite` 的 Hermes profile skills。
|
||||||
|
- 普通用户可以修改自己的 personal Hermes profile skills。
|
||||||
|
- MNote 内置 skill 的显示/隐藏与启停是两个不同状态:隐藏只影响 UI,启停影响服务端可调用性。
|
||||||
|
- Hermes profile 切换后 skill catalog 正确刷新。
|
||||||
|
- Reasonix 默认 memory off,开启 memory 是 per-user preference。
|
||||||
|
- Page AI run payload 不再用裸 Hermes profile name 表达身份,而是经服务端解析的 `agentProfileRef` / `profileId`。
|
||||||
|
- 所有文件访问仍受 SQLite directory grants / allowedRoots 约束。
|
||||||
@@ -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="Agent:Reasonix" title="Agent:Reasonix">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);
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user