feat: expand page ai hermes control surface

This commit is contained in:
lix-2026
2026-05-14 17:04:17 +08:00
parent d1d19f755c
commit f257b2a014
6 changed files with 1447 additions and 50 deletions
@@ -2,7 +2,7 @@
> 更新时间:2026-05-14
>
> 当前状态:`PROCESS`。当前实现已经可以通过 Hermes 返回回复,并已完成 `7-4` 的 session/run/tool/writeback/audit 主链;本稿只承接下一阶段体验与设置面的收口。
> 当前状态:`PROCESS`。当前实现已经可以通过 Hermes 返回回复,并已完成 `7-4` 的 session/run/tool/writeback/audit 主链;本稿只承接下一阶段体验与 agent / skill / 设置面的收口。当前已补上 `agent/profile` 选择、`SOUL.md` / `USER.md` / `MEMORY.md` 轻编辑、skill 启停和对应 smoke,但 stop/retry、完整 settings 跳转、tool audit 完整面还未收口。
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
@@ -24,14 +24,20 @@
下一阶段不要把 mnote 做成第二个 Hermes 管理台,也不要把 Hermes 的 Settings / Profiles / Usage / Logs 全部搬进页面抽屉。正确方向是:
> **页面 AI 面板成为 Mini Hermes control surface:只展示和当前 mnote 页面会话直接相关的 session、run、model、profile、tool、context scope 与错误恢复;所有全局配置真相仍归 Hermes。**
> **页面 AI 面板成为 Mini Hermes control surface:只管理和当前 mnote 页面会话直接相关的 session、run、agent/profile、model、skill、tool、context scope 与错误恢复;所有全局配置真相仍归 Hermes。**
边界继续保持:
- Hermes 持有 session/message/tool event/usage/model/profile 真相。
- mnote 只持有页面、树、正文、artifact、edge、Page Aggregate 和 audit 真相。
- 页面 AI 面板只调用 Hermes,不保存聊天历史,不维护 provider/API key,不重建 plugin registry。
- 需要深层设置时跳转或 deep link 到 Hermes 自己的设置页
- 页面 AI 面板可以选择 agent/profile、启停 skill、编辑当前 agent 的轻量人格文件;保存和生效都必须走 Hermes API
- provider/API key、账号登录、全局 marketplace、profile 导入导出等深层设置跳转或 deep link 到 Hermes 自己的设置页。
用户提供的参考图只作为能力边界参考,不要求照搬 UI:
- `/mnt/Data1T/mnote/tmp/img_v3_0211m_b7390ad9-a45b-4d94-8126-b7c17f029feg.png`AI 人格页,包含 `SOUL.md``IDENTITY.md``USER.md` 类 profile 文件编辑和保存。
- `/mnt/Data1T/mnote/tmp/img_v3_0211m_0d6e7a60-8c21-4fd1-8cdf-d548dab7532g.png`:技能页,包含搜索、上传、自定义技能额度、单个技能启停和详情查看。
---
@@ -53,17 +59,20 @@
| tool 消息展示 | `packages/client/src/components/hermes/chat/MessageItem.vue` | `message.role === 'tool'``formatToolPayload``tool-preview``tool-details``tool-error-badge` | 对齐 tool 结果默认折叠、摘要、错误 badge、详情展开 | 不把 tool result 当正文写入,不扩大为审计详情管理台 |
| session 列表项 | `packages/client/src/components/hermes/chat/SessionListItem.vue` | `session-item-title``session.title``session-item-model` | 对齐 session 标题、模型和时间的展示语义 | 不把 Hermes session title 同步为 mnote page title |
| model 选择展示 | `packages/client/src/components/layout/ModelSelector.vue` | `selectedDisplayName``handleSelect``model-name``model-item` | 对齐模型显示名称、选择行为和禁用态 | 不在 mnote 保存 model/provider/API key 真相 |
| agent/profile 选择 | `packages/client/src/components/layout/ProfileSelector.vue``packages/client/src/api/hermes/profiles.ts``packages/client/src/stores/hermes/profiles.ts` | `fetchProfiles``switchProfile``activeProfileName``handleChange` | 对齐 Hermes profile 作为 agent 的选择语义;mnote 面板选择的是 profile,不是自建 agent 表 | 不要求页面刷新;不直接复制 Hermes sidebar profile selector |
| profile 管理边界 | `packages/client/src/views/hermes/ProfilesView.vue``packages/client/src/components/hermes/profiles/ProfileCard.vue``ProfileCreateModal.vue``ProfileRenameModal.vue``packages/server/src/routes/hermes/profiles.ts` | `/api/hermes/profiles``/api/hermes/profiles/active``rename``create``delete` | 只参考 profile list/detail/rename 的字段和错误边界 | 第一阶段不做 import/export/delete;不把 mnote 做成完整 ProfilesView |
| agent 人格文件编辑 | `packages/client/src/views/hermes/MemoryView.vue``packages/client/src/api/hermes/skills.ts``packages/server/src/routes/hermes/memory.ts``packages/server/src/controllers/hermes/memory.ts` | `fetchMemory``saveMemory``memory/user/soul``SOUL.md``USER.md``MEMORY.md` | 对齐 Hermes 如何读取和保存人格/记忆文件;mnote 可提供当前 agent 的轻量编辑器 | 不在 mnote 直接读写 profile 目录;不绕过 Hermes API 写文件 |
| run 生命周期与持久化 | `packages/server/src/services/hermes/chat-run-socket.ts` | `handleRun``emit``applyResponseStreamEvent``flushResponseRunToDb``markCompleted` | 对齐 queued/running/tool_calling/completed/failed 与 flush 到 Hermes DB 的时机 | 不在 mnote 里重写 Hermes 编排器 |
| session 存储真相 | `packages/server/src/db/hermes/session-store.ts` | `HermesSessionRow``HermesMessageRow``getSessionDetail``addMessage``renameSession` | 明确 session/message/tool call 真相字段来自 Hermes | 不在 mnote 建第二份 chat/session 表 |
| plugin/tool 发现 | `packages/server/src/services/hermes/plugins.ts` | `PluginManager``providesTools``listHermesPlugins``requiresEnv` | 对齐 mnote tools 作为 Hermes plugin/tool registry 的一部分被发现 | 不把 mnote-web 私有 route 当成最终 plugin registry |
| skill/plugin API 外观 | `packages/client/src/api/hermes/plugins.ts``packages/client/src/api/hermes/skills.ts` | `list``get``enable``disable` 类方法 | 仅用于面板显示 mnote plugin/tool 可用性和缺失状态 | 不实现完整 Hermes plugin/skill 管理页 |
| skill/plugin API 外观 | `packages/client/src/api/hermes/plugins.ts``packages/client/src/api/hermes/skills.ts``packages/client/src/views/hermes/SkillsView.vue``packages/client/src/components/hermes/skills/SkillList.vue``SkillDetail.vue``packages/server/src/routes/hermes/skills.ts` | `fetchSkills``toggleSkill``pinSkillApi``fetchSkillContent``fetchSkillFiles``/api/hermes/skills/toggle` | 对齐 skill 搜索、分类、启停、详情和文件查看;页面 AI 需要能启停技能 | 不实现完整 marketplace、批量安装、复杂上传发布流程 |
| 不进入 mnote 面板第一阶段 | `packages/client/src/views/hermes/SettingsView.vue``ProfilesView.vue``UsageView.vue``LogsView.vue``JobsView.vue``FilesView.vue``ChannelsView.vue``TerminalView.vue``GroupChatView.vue` | 页面级管理视图入口 | 用来明确边界:这些属于 Hermes 管理台 | 不搬进 mnote 页面 AI 抽屉 |
快速定位命令:
```bash
cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18
rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunViaSocket|mapHermesMessages|tool\\.started|tool\\.completed|activeSessionTitle|formatToolPayload|selectedDisplayName|handleRun|flushResponseRunToDb|PluginManager|providesTools" packages/client/src packages/server/src
rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunViaSocket|mapHermesMessages|tool\\.started|tool\\.completed|activeSessionTitle|formatToolPayload|selectedDisplayName|fetchProfiles|switchProfile|fetchMemory|saveMemory|toggleSkill|SkillList|handleRun|flushResponseRunToDb|PluginManager|providesTools" packages/client/src packages/server/src
```
---
@@ -73,6 +82,9 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
### 3.1 必须进入 mnote 页面 AI 面板
- 当前 Hermes session 标题、session id、模型名、profile 名和恢复状态。
- agent/profile 选择:用户能在页面 AI 面板里选择当前使用的 Hermes profileprofile 等同本设计中的 agent。
- agent/profile 轻编辑:用户能查看和修改当前 agent 的 `SOUL.md``USER.md``MEMORY.md`,若 Hermes 后续暴露 `IDENTITY.md` 也按同一文件编辑模型接入。
- skill 启停:用户能搜索 Hermes skills,查看简要说明,并启用/停用单个 skill。
- 当前 run 状态:`idle``queued``running``tool_calling``completed``failed``aborted`
- 当前页面上下文范围:当前页、当前选区、当前块、全文、页面设置。
- 当前 mnote tools 可用性:`mnote.page.get``mnote.page.save``mnote.page.update_title``mnote.page.update_options``mnote.artifact.create_summary``mnote.artifact.create_ai_note`
@@ -83,8 +95,8 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
### 3.2 不进入 mnote 页面 AI 面板
- provider/API key 密钥管理。
- 全局 profile 创建、删除、复杂编辑
- Hermes plugin marketplace / skill marketplace 管理
- 全局 profile 导入导出、删除、批量迁移、凭据清理等危险操作
- Hermes plugin marketplace / skill marketplace 的安装、发布、版本管理和批量上传
- usage 报表、日志中心、任务中心、文件中心、频道、终端、群聊。
- Hermes session 数据库迁移、导入导出、conversation 管理台。
- 独立 mnote 聊天历史存储。
@@ -93,7 +105,9 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
- 当前页面最近 sessions 搜索。
- session 重命名与删除。
- profile/model 下拉切换
- profile rename / create
- model 下拉切换。
- skill pin、skill 文件详情查看、单个本地 skill 上传。
- tool event JSON 详情复制。
- session deep link 到 Hermes 管理台。
@@ -101,12 +115,13 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
## 4. 目标信息架构
页面 AI 抽屉保持 Wolai 对齐后的壳层,内部按层组织:
页面 AI 抽屉保持 Wolai 对齐后的壳层,内部按层组织:
1. 顶部状态条:session 标题、模型/profile、连接状态、设置跳转。
1. 顶部状态条:session 标题、agent/profile、模型、连接状态、设置跳转。
2. 消息区:Hermes message list、streaming 回复、tool event 折叠项。
3. 上下文与工具条:当前 context scope、可用 mnote tools、最近 audit/trace
4. 输入区:prompt 输入、发送、停止、重试、继续
3. Agent 设置区:profile 选择、SOUL/USER/MEMORY 轻编辑、skill 搜索与启停
4. 上下文与工具条:当前 context scope、可用 mnote tools、最近 audit/trace
5. 输入区:prompt 输入、发送、停止、重试、继续。
验收时不要只看 DOM,应以截图和交互为准:
@@ -114,6 +129,7 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
- tool event 折叠项不会撑破抽屉宽度。
- 失败态能看到可执行动作,不只是一段错误文本。
- 设置入口不会暗示 mnote 自己持有 API key。
- agent/profile 选择和 skill 启停必须明确显示“由 Hermes 保存 / 全局生效或当前 profile 生效”的作用域,避免用户误以为只是当前页面临时开关。
---
@@ -125,6 +141,10 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
- `GET /api/hermes/client/sessions?documentId=...&limit=...`
- `GET /api/hermes/client/session/:sessionId`
- `GET /api/hermes/client/models`
- `GET /api/hermes/client/profiles`
- `GET /api/hermes/client/profiles/:profileName`
- `GET /api/hermes/client/profile-memory?profile=...`
- `GET /api/hermes/client/skills?profile=...&query=...`
- `GET /api/hermes/client/tools?scope=mnote`
- `GET /api/hermes/client/runtime/status`
@@ -138,10 +158,59 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
- `POST /api/hermes/client/session/:sessionId/resume`
- `POST /api/hermes/client/session/:sessionId/rename`
- `DELETE /api/hermes/client/session/:sessionId`
- `PUT /api/hermes/client/profiles/active`
- `POST /api/hermes/client/profile-memory`
- `PUT /api/hermes/client/skills/toggle`
第一阶段可以只完成 `run/stop/resume``retry/rename/delete` 可以排到 P2,但 UI 文案不能让用户误以为已经支持。
第一阶段必须完成 `run/stop/resume`、profile 选择、skill 单项启停、`SOUL.md` / `USER.md` / `MEMORY.md` 轻编辑。`retry/rename/delete`、profile create/rename、skill 上传可以排到 P2,但 UI 文案不能让用户误以为已经支持。
### 5.3 mnote tool manifest
### 5.3 Agent / profile 选择语义
在本稿中,“agent” 等同 Hermes profile。页面 AI 不创建 mnote 私有 agent 表。
选择 profile 时必须处理 session 归属:
- 若当前没有 Hermes session,选择 profile 后创建新 sessionsession 的 `profile` 字段由 Hermes 持有。
- 若当前 session 已绑定 profile,用户切换 profile 时默认创建一个新的当前页 session;不要原地篡改旧 session 的 profile。
- 页面可以记住“当前页面最后使用的 session id / profile name”作为 UI 恢复线索,但聊天真相仍以 Hermes session detail 为准。
- profile 切换不自动改页面正文,不自动触发写入工具。
### 5.4 Agent 人格文件编辑语义
页面 AI 面板允许轻编辑当前 profile 的人格文件:
- `SOUL.md`:核心人格、价值观、行为边界。
- `USER.md`:用户偏好与长期上下文。
- `MEMORY.md`Hermes memory 笔记。
- `IDENTITY.md`:若 Hermes API 暴露,则作为同类 profile file 接入;未暴露时 UI 不显示假入口。
保存规则:
- 通过 Hermes API 保存,不由 mnote 直接读写 `.hermes` 文件。
- 保存前显示本次修改作用域:当前 Hermes profile。
- 保存后只影响后续 run;已完成的 Hermes session/message 不回写。
- 保存失败必须显示 Hermes 返回的原因,不能静默保留本地草稿并显示成功。
### 5.5 Skill 启停语义
页面 AI 面板允许搜索、查看和启停 skill。
第一阶段只要求:
- 列表来自 Hermes `/api/hermes/skills` 或 mnote-web 同源 proxy。
- 单个 skill 的 `enabled` 状态由 Hermes 持有。
- toggle 调用 Hermes `/api/hermes/skills/toggle` 语义。
- UI 明确展示 skill 来源:builtin / hub / local / modified。
- 禁用 skill 影响后续 run;正在运行的 run 不强制中断,除非 Hermes upstream 本身返回中断。
不在第一阶段做:
- marketplace 安装/发布。
- 批量启停。
- 删除 builtin skill。
- 上传并发布完整 skill 包。单个本地 skill 上传可排 P2,且仍走 Hermes。
### 5.6 mnote tool manifest
页面 AI 面板展示 tool 可用性时应优先从 Hermes plugin/tool registry 来,而不是硬编码前端列表。允许 mnote-web 在过渡期提供同源聚合:
@@ -170,7 +239,7 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
### B. 面板状态模型
- [ ] B1. 定义 Leptos 侧 `HermesPanelState`
- 验收标准:状态至少包含 `sessionId``sessionTitle``modelName``profileName``runStatus``connectionStatus``contextScope``toolAvailability``lastAudit`
- 验收标准:状态至少包含 `sessionId``sessionTitle``agentProfileName``profileFilesState``skillListState``modelName``runStatus``connectionStatus``contextScope``toolAvailability``lastAudit`
- [ ] B2. 明确哪些字段来自 Hermes,哪些字段来自 mnote。
- 验收标准:session/message/model/profile/run/tool event 来自 Hermespage title/context/audit 来自 mnote;没有字段把 Hermes session title 当作页面标题。
- [ ] B3. 增加空态、连接失败态、tool 不可用态。
@@ -180,10 +249,23 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
- [ ] C1. 展示当前 session 标题和恢复状态。
- 验收标准:刷新页面后能从 Hermes session detail 恢复标题和消息,不从 mnote 本地 state 恢复聊天真相。
- [ ] C2. 展示 model/profile 摘要。
- 验收标准:能看到当前模型与 profile;缺失时显示“去 Hermes 配置”的动作,而不是在 mnote 内要求填写 API key。
- [ ] C2. 展示 model/agent profile 摘要。
- 验收标准:能看到当前模型与 agent profile;缺失时显示“选择 agent / 去 Hermes 配置”的动作,而不是在 mnote 内要求填写 API key。
- [ ] C3. 增加设置跳转入口。
- 验收标准:入口跳到 Hermes 设置或 profile 页面;mnote 页面内不出现 provider/API key 编辑表单。
- [ ] C4. 支持 agent/profile 选择。
- 验收标准:profile 列表来自 Hermes;选择新 profile 后新建或切换到该 profile 的 Hermes session,不原地篡改旧 session profile。
### C5. Agent 人格轻编辑
- [ ] C5.1 展示当前 agent 的人格文件入口。
- 验收标准:至少展示 `SOUL.md``USER.md``MEMORY.md``IDENTITY.md` 只在 Hermes API 暴露时展示。
- [ ] C5.2 支持编辑并保存 `SOUL.md`
- 验收标准:保存调用 Hermes API;保存失败不显示成功;保存后新 run 使用更新后人格。
- [ ] C5.3 支持编辑并保存 `USER.md` / `MEMORY.md`
- 验收标准:保存作用域显示为当前 Hermes profilemnote 不直接写 `.hermes` 文件。
- [ ] C5.4 防止运行中误改。
- 验收标准:run 正在进行时编辑器可以查看,但保存按钮禁用或明确提示“保存只影响后续 run”。
### D. Run 控制与恢复
@@ -216,6 +298,17 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
- [ ] F4. 最近 tool call 关联 audit。
- 验收标准:能从 UI 或测试输出看到 `sessionId/runId/toolCallId/traceId/auditId` 的串联。
### F5. Skill 管理最小面
- [ ] F5.1 展示 Hermes skills 列表和搜索。
- 验收标准:列表来自 Hermes skills API;能按名称/描述过滤;显示 builtin / hub / local / modified 来源。
- [ ] F5.2 支持单个 skill 启停。
- 验收标准:开关调用 Hermes `toggleSkill` 语义;刷新后状态仍由 Hermes 返回;失败时回滚 UI 开关。
- [ ] F5.3 支持查看 skill 详情。
- 验收标准:能查看 `SKILL.md` 摘要或详情;长内容不撑破抽屉;详情来源于 Hermes API。
- [ ] F5.4 明确 skill toggle 作用域。
- 验收标准:UI 说明启停影响当前 Hermes profile 或 Hermes 全局配置;不把它描述成仅当前 mnote 页面临时开关。
### G. Session 列表最小面
- [ ] G1. 支持当前页面最近 Hermes sessions 列表。
@@ -238,13 +331,19 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
- [ ] I1. 新增或扩展 browser smokesession 状态条。
- 验收标准:断言 session title/model/run status 可见。
- [ ] I2. 新增或扩展 browser smokerun stop/retry
- [ ] I2. 新增或扩展 browser smokeagent/profile 选择
- 验收标准:断言 profile 列表来自 Hermes;切换 profile 后新 run/session 使用目标 profile。
- [ ] I3. 新增或扩展 browser smokeagent 人格文件编辑。
- 验收标准:保存 `SOUL.md``USER.md` 后通过 Hermes API 读回一致;运行中保存行为符合禁用或提示规则。
- [ ] I4. 新增或扩展 browser smokeskill 启停。
- 验收标准:toggle 后刷新仍保持 Hermes 返回状态;失败时 UI 回滚。
- [ ] I5. 新增或扩展 browser smokerun stop/retry。
- 验收标准:至少覆盖 stop;retry 若未实现则断言按钮不存在或禁用。
- [ ] I3. 新增或扩展 browser smokecontext scope。
- [ ] I6. 新增或扩展 browser smokecontext scope。
- 验收标准:选区/当前页上下文能进入 run payload 或 tool audit。
- [ ] I4. 新增或扩展 browser smoketool 可用性与 audit。
- [ ] I7. 新增或扩展 browser smoketool 可用性与 audit。
- 验收标准:能看到 mnote tool 列表、一次 tool call、对应 audit id。
- [ ] I5. 移动端 smoke。
- [ ] I8. 移动端 smoke。
- 验收标准:状态条、tool 折叠、输入区在窄屏不重叠。
---
@@ -253,13 +352,16 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
本稿移入 `done/` 前必须同时满足:
- [ ] 页面 AI 抽屉具备 Mini Hermes 状态条,用户能看见当前 session、model/profile、run 状态。
- [ ] 页面 AI 抽屉具备 Mini Hermes 状态条,用户能看见当前 session、model/agent profile、run 状态。
- [ ] 页面 AI 抽屉能选择 agent/profile;切换 agent 不篡改已有 Hermes session profile。
- [ ] 页面 AI 抽屉能轻编辑当前 agent 的 `SOUL.md``USER.md``MEMORY.md`,保存真相在 Hermes。
- [ ] 页面 AI 抽屉能搜索、查看并启停 Hermes skill,刷新后状态仍以 Hermes 返回为准。
- [ ] 页面 AI 抽屉具备 context scope 控件,run input 和 tool audit 能反映 scope。
- [ ] 页面 AI 抽屉能展示 mnote tool 可用性、最近 tool call 和 audit/trace 串联。
- [ ] stop 至少可用;retry/rename/delete 若未实现,必须明确标为 P2 且 UI 不出现假可用按钮。
- [ ] Hermes 未启动、模型缺失、plugin/tool 不可用、权限失败至少四类错误有可区分展示。
- [ ] 页面刷新后仍以 Hermes session detail/resume 为会话真相。
- [ ] mnote 不保存聊天消息真相,不保存 provider/API key,不创建第二套 plugin registry。
- [ ] mnote 不保存聊天消息真相,不保存 provider/API key,不创建第二套 profile/skill/plugin registry。
- [ ] `git diff --check` 通过。
- [ ] Rust 侧相关测试通过:`cargo test -p mnote-web hermes_client -- --nocapture``cargo test -p mnote-web hermes_tools_ -- --nocapture`
- [ ] 正式 `3000` smoke 覆盖 session/run/tool/audit/mobile,且证据写回本文。
@@ -270,8 +372,9 @@ rg -n "StartRunRequest|RunEvent|registerSessionHandlers|resumeSession|startRunVi
优先顺序:
1. 先补顶部 Mini Hermes 状态条和错误态,因为这是当前“能回复但缺 Hermes 设置感”的直接缺口。
2. 再补 context scope 和 tool 可用性,让用户明确 Hermes 正在调用 mnote 的哪些能力
3. 最后补 session 列表、rename/delete/search 等历史管理能力。
1. 先补顶部 Mini Hermes 状态条、agent/profile 选择和错误态,因为这是当前“能回复但缺 Hermes 设置感”的直接缺口。
2. 再补 skill 搜索/启停和 agent 人格轻编辑,让用户能在 mnote 页面内完成常用 agent 调整
3. 再补 context scope 和 tool 可用性,让用户明确 Hermes 正在调用 mnote 的哪些能力。
4. 最后补 session 列表、rename/delete/search、profile create/rename、skill 上传等 P2 能力。
不要先做 Hermes 全局设置页复刻。API key、provider、全局 profile、usage/logs/jobs/files/channels 仍应留在 Hermes 自己的管理台。
不要先做 Hermes 全局设置页复刻。API key、provider、usage/logs/jobs/files/channels、profile 导入导出、skill marketplace 发布仍应留在 Hermes 自己的管理台。
@@ -22,6 +22,7 @@ pub struct CreateSessionRequest {
document_id: Option<String>,
trace_id: Option<String>,
title: Option<String>,
profile: Option<String>,
}
pub async fn list_sessions(
@@ -60,6 +61,12 @@ pub async fn create_session(
.filter(|value| !value.trim().is_empty())
.unwrap_or("current");
let session_id = stable_session_id(document_id, &trace_id);
let profile = payload
.profile
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default");
Ok((
StatusCode::OK,
stamp_client_headers(),
@@ -68,6 +75,7 @@ pub async fn create_session(
"sessionId": session_id,
"workspaceId": payload.workspace_id,
"documentId": payload.document_id,
"profile": profile,
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
"traceId": trace_id,
"persistence": "hermes_on_first_run"
@@ -75,6 +83,133 @@ pub async fn create_session(
))
}
pub async fn list_profiles(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
proxy_json(
&context,
reqwest::Method::GET,
&upstream,
"/api/hermes/profiles",
None,
)
.await
}
pub async fn get_profile(
Extension(context): Extension<RequestContext>,
Path(profile_name): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
proxy_json(
&context,
reqwest::Method::GET,
&upstream,
&format!("/api/hermes/profiles/{}", url_escape(&profile_name)),
None,
)
.await
}
pub async fn switch_active_profile(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
proxy_json(
&context,
reqwest::Method::PUT,
&upstream,
"/api/hermes/profiles/active",
Some(payload),
)
.await
}
pub async fn get_profile_memory(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
proxy_json(
&context,
reqwest::Method::GET,
&upstream,
"/api/hermes/memory",
None,
)
.await
}
pub async fn save_profile_memory(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
proxy_json(
&context,
reqwest::Method::POST,
&upstream,
"/api/hermes/memory",
Some(payload),
)
.await
}
pub async fn list_skills(
Extension(context): Extension<RequestContext>,
Query(query): Query<HashMap<String, String>>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
let mut path = "/api/hermes/skills".to_string();
if !query.is_empty() {
let params = query
.iter()
.map(|(key, value)| format!("{}={}", url_escape(key), url_escape(value)))
.collect::<Vec<_>>()
.join("&");
path.push('?');
path.push_str(&params);
}
proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await
}
pub async fn toggle_skill(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let Some(upstream) = configured_upstream() else {
return hermes_unconfigured(&context);
};
proxy_json(
&context,
reqwest::Method::PUT,
&upstream,
"/api/hermes/skills/toggle",
Some(payload),
)
.await
}
pub async fn get_session(
Extension(context): Extension<RequestContext>,
Path(session_id): Path<String>,
@@ -335,10 +470,16 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
let page_context = payload.get("pageContext").cloned().unwrap_or(Value::Null);
let workspace_id = payload.get("workspaceId").cloned().unwrap_or(Value::Null);
let profile = payload
.get("profile")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let instructions = json!({
"role": "mnote_page_ai_context",
"workspaceId": workspace_id,
"documentId": document_id,
"profile": profile.unwrap_or("default"),
"actorId": context.auth.actor_id,
"actorType": context.auth.actor_type,
"sessionId": session_id,
@@ -362,6 +503,9 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
{
body["model"] = Value::String(model.to_string());
}
if let Some(profile) = profile {
body["profile"] = Value::String(profile.to_string());
}
Ok(body)
}
@@ -664,6 +808,39 @@ mod tests {
assert!(payload.get("messages").is_none());
}
#[tokio::test]
async fn hermes_client_session_create_carries_agent_profile_without_storing_chat() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/client/sessions")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
json!({
"workspaceId": "ws_1",
"documentId": "doc_1",
"traceId": "trace_1",
"title": "当前页问答",
"profile": "chemist"
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
assert_eq!(payload["profile"], "chemist");
assert!(payload.get("messages").is_none());
}
#[test]
fn hermes_client_run_body_carries_page_context_into_run_input() {
let context = RequestContext::from_http_parts(
@@ -693,4 +870,77 @@ mod tests {
assert!(instructions.contains("\"title\":\"页面标题\""));
assert!(instructions.contains("\"selectedBlockId\":\"block_1\""));
}
#[test]
fn hermes_client_run_body_carries_agent_profile() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/client/runs".parse().expect("uri"),
&HeaderMap::new(),
);
let body = build_run_upstream_body(
&context,
json!({
"workspaceId": "ws_1",
"documentId": "doc_1",
"sessionId": "sess_1",
"message": "用当前 agent 回答",
"profile": "chemist",
"traceId": "trace_1"
}),
)
.expect("body");
assert_eq!(body["profile"], "chemist");
let instructions = body["instructions"].as_str().expect("instructions");
assert!(instructions.contains("\"profile\":\"chemist\""));
}
#[tokio::test]
async fn hermes_client_profile_skill_and_memory_routes_exist_with_stable_unconfigured_error() {
let _guard = env_lock().lock().expect("env lock");
std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL");
std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL");
let cases = [
("GET", "/api/hermes/client/profiles", None),
("GET", "/api/hermes/client/profiles/chemist", None),
("GET", "/api/hermes/client/profile-memory?profile=chemist", None),
("GET", "/api/hermes/client/skills?profile=chemist", None),
(
"PUT",
"/api/hermes/client/profiles/active",
Some(json!({"name": "chemist"})),
),
(
"POST",
"/api/hermes/client/profile-memory",
Some(json!({"profile": "chemist", "section": "soul", "content": "test"})),
),
(
"PUT",
"/api/hermes/client/skills/toggle",
Some(json!({"name": "chem", "enabled": true})),
),
];
for (method, uri, body) in cases {
let request = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.body(Body::from(
body.unwrap_or_else(|| json!({})).to_string(),
))
.expect("request");
let response = app().oneshot(request).await.expect("response");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{uri}");
assert_eq!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("hermes_client_unconfigured"),
"{uri}"
);
}
}
}
+23 -1
View File
@@ -27,7 +27,7 @@ pub(crate) mod web_shell;
mod ws;
use crate::app::AppState;
use axum::routing::{any, get, post};
use axum::routing::{any, get, post, put};
use axum::Router;
pub fn build_router(state: AppState) -> Router {
@@ -145,6 +145,28 @@ pub fn build_router(state: AppState) -> Router {
"/client/sessions/{session_id}",
get(hermes_client::get_session),
)
.route("/client/profiles", get(hermes_client::list_profiles))
.route(
"/client/profiles/active",
put(hermes_client::switch_active_profile),
)
.route(
"/client/profiles/{profile_name}",
get(hermes_client::get_profile),
)
.route(
"/client/profile-memory",
get(hermes_client::get_profile_memory)
.post(hermes_client::save_profile_memory),
)
.route(
"/client/skills",
get(hermes_client::list_skills),
)
.route(
"/client/skills/toggle",
put(hermes_client::toggle_skill),
)
.route("/client/runs", post(hermes_client::create_run))
.route("/client/events/{run_id}", get(hermes_client::stream_events))
.route(
+579 -22
View File
@@ -36,6 +36,19 @@ const SIDEBAR_TREE_JS: &str = r##"
pageAiSuggestionIndex: 0,
pageAiProvider: 'hermes',
pageAiPage: 'chat',
pageAiRunStatus: 'idle',
pageAiCurrentRunId: '',
pageAiAbortController: null,
pageAiContextScope: 'page',
pageAiProfiles: [],
pageAiActiveProfileName: 'default',
pageAiProfileError: '',
pageAiProfileMemory: { memory: '', user: '', soul: '' },
pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' },
pageAiProfileMemoryError: '',
pageAiSkills: { categories: [], archived: [] },
pageAiSkillQuery: '',
pageAiSkillError: '',
pageAiSessions: [],
pageAiActiveSessionId: ''
};
@@ -3022,6 +3035,7 @@ const SIDEBAR_TREE_JS: &str = r##"
return {
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
title: title || '新会话',
profile: pageAiCurrentProfile(),
createdAt: now,
updatedAt: now,
messages: []
@@ -3035,6 +3049,7 @@ const SIDEBAR_TREE_JS: &str = r##"
return {
id: String(session && session.id || '').trim() || pageAiNewSession().id,
title: String(session && session.title || '').trim() || '新会话',
profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default',
createdAt: Number(session && session.createdAt || Date.now()),
updatedAt: Number(session && session.updatedAt || Date.now()),
messages: Array.isArray(session && session.messages) ? session.messages.slice(-40) : []
@@ -3051,6 +3066,8 @@ const SIDEBAR_TREE_JS: &str = r##"
var raw = window.localStorage.getItem(pageAiStorageKey());
var parsed = raw ? JSON.parse(raw) : null;
var activeId = String(parsed && parsed.activeSessionId || '').trim();
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
if (activeProfile) pageAiSetActiveProfile(activeProfile);
if (activeId) {
pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')];
pageUiState.pageAiSessions[0].id = activeId;
@@ -3070,15 +3087,16 @@ const SIDEBAR_TREE_JS: &str = r##"
document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
try {
window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
activeSessionId: pageUiState.pageAiActiveSessionId
activeSessionId: pageUiState.pageAiActiveSessionId,
activeProfileName: pageAiCurrentProfile()
}));
} catch (_) {}
}
async function pageAiEnsureHermesSession() {
async function pageAiEnsureHermesSession(forceCreate) {
pageAiLoadSessions();
var current = pageAiCurrentSession();
if (current && String(current.id || '').startsWith('mnote_')) return current;
if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === pageAiCurrentProfile()) return current;
var response = await fetch('/api/hermes/client/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -3086,6 +3104,7 @@ const SIDEBAR_TREE_JS: &str = r##"
workspaceId: resolveWorkspaceId(document.body),
documentId: currentDocumentId(),
traceId: 'page-ai-' + Date.now().toString(36),
profile: pageAiCurrentProfile(),
title: current && current.title ? current.title : '当前页问答'
})
});
@@ -3097,6 +3116,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var session = {
id: String(payload.sessionId || '').trim(),
title: String(payload.title || '当前页问答'),
profile: String(payload.profile || pageAiCurrentProfile()).trim() || 'default',
createdAt: Date.now(),
updatedAt: Date.now(),
messages: pageUiState.pageAiMessages.slice()
@@ -3118,6 +3138,9 @@ const SIDEBAR_TREE_JS: &str = r##"
var payload = await response.json().catch(function(){ return null; });
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
var messages = session && Array.isArray(session.messages) ? session.messages : [];
if (session && (session.profile || session.profileName)) {
current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile());
}
if (!messages.length) return;
pageUiState.pageAiMessages = messages.slice(-40).map(function(message) {
return {
@@ -3143,6 +3166,7 @@ const SIDEBAR_TREE_JS: &str = r##"
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
pageUiState.pageAiPage = 'chat';
pageAiPersistSessions();
renderPageAiControls();
renderPageAiConversation();
}
@@ -3153,6 +3177,7 @@ const SIDEBAR_TREE_JS: &str = r##"
pageUiState.pageAiMessages = [];
pageUiState.pageAiPage = 'chat';
pageAiPersistSessions();
renderPageAiControls();
renderPageAiConversation();
}
@@ -3162,6 +3187,316 @@ const SIDEBAR_TREE_JS: &str = r##"
return 'Hermes';
}
function pageAiNormalizeArray(value) {
return Array.isArray(value) ? value : [];
}
function pageAiUnwrapUpstream(payload) {
if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream;
return payload || null;
}
function pageAiProfileValue(profile) {
if (profile && typeof profile === 'object') {
return String(profile.name || profile.profile || profile.id || '').trim();
}
return String(profile || '').trim();
}
function pageAiCurrentProfile() {
var active = String(pageUiState.pageAiActiveProfileName || '').trim();
if (active) return active;
var selected = pageUiState.pageAiProfiles.find(function(profile) {
return profile && profile.active;
});
return pageAiProfileValue(selected) || 'default';
}
function pageAiNormalizeProfiles(payload) {
var upstream = pageAiUnwrapUpstream(payload);
var profiles = pageAiNormalizeArray(upstream && upstream.profiles ? upstream.profiles : upstream);
return profiles.map(function(profile) {
return {
name: pageAiProfileValue(profile) || 'default',
active: Boolean(profile && profile.active),
model: String(profile && profile.model || '').trim(),
gateway: String(profile && profile.gateway || '').trim(),
alias: String(profile && profile.alias || '').trim()
};
});
}
function pageAiNormalizeSkills(payload) {
var upstream = pageAiUnwrapUpstream(payload);
var categories = pageAiNormalizeArray(upstream && upstream.categories ? upstream.categories : []);
var archived = pageAiNormalizeArray(upstream && upstream.archived ? upstream.archived : []);
return {
categories: categories.map(function(category) {
return {
name: String(category && category.name || '').trim() || 'misc',
description: String(category && category.description || '').trim(),
skills: pageAiNormalizeArray(category && category.skills).map(function(skill) {
return {
name: String(skill && skill.name || '').trim(),
description: String(skill && skill.description || '').trim(),
enabled: skill && skill.enabled !== false,
source: String(skill && skill.source || 'local').trim() || 'local',
modified: Boolean(skill && skill.modified)
};
})
};
}),
archived: archived.map(function(skill) {
return {
name: String(skill && skill.name || '').trim(),
description: String(skill && skill.description || '').trim(),
enabled: skill && skill.enabled !== false,
source: String(skill && skill.source || 'local').trim() || 'local',
modified: Boolean(skill && skill.modified)
};
})
};
}
function pageAiSkillListEntries() {
var result = [];
pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) {
pageAiNormalizeArray(category.skills).forEach(function(skill) {
result.push({
category: category.name,
name: skill.name,
description: skill.description,
enabled: skill.enabled !== false,
source: skill.source || 'local',
modified: Boolean(skill.modified)
});
});
});
return result.concat(pageAiNormalizeArray(pageUiState.pageAiSkills.archived));
}
function pageAiSetActiveProfile(profileName) {
var next = String(profileName || '').trim() || 'default';
pageUiState.pageAiActiveProfileName = next;
document.documentElement.setAttribute('data-mnote-page-ai-profile', next);
}
function pageAiSetRunStatus(status, runId) {
pageUiState.pageAiRunStatus = status || 'idle';
pageUiState.pageAiCurrentRunId = runId || pageUiState.pageAiCurrentRunId || '';
document.documentElement.setAttribute('data-mnote-page-ai-run-status', pageUiState.pageAiRunStatus);
if (pageUiState.pageAiCurrentRunId) {
document.documentElement.setAttribute('data-mnote-page-ai-run-id', pageUiState.pageAiCurrentRunId);
}
}
function pageAiSetContextScope(scope) {
var next = String(scope || '').trim() || 'page';
pageUiState.pageAiContextScope = next;
document.documentElement.setAttribute('data-mnote-page-ai-context-scope', next);
}
function pageAiRunStatusLabel(status) {
if (status === 'queued') return '排队中';
if (status === 'running') return '运行中';
if (status === 'tool_calling') return '调用工具';
if (status === 'completed') return '已完成';
if (status === 'failed') return '失败';
if (status === 'aborted') return '已停止';
return '空闲';
}
function pageAiMemoryFileLabel(section) {
if (section === 'soul') return 'SOUL.md';
if (section === 'user') return 'USER.md';
return 'MEMORY.md';
}
function pageAiContextScopeLabel(scope) {
if (scope === 'selection') return '当前选区';
if (scope === 'block') return '当前块';
if (scope === 'options') return '页面设置';
return '当前页';
}
function pageAiSetDraftForSection(section, value) {
pageUiState.pageAiProfileMemoryDrafts[section] = String(value == null ? '' : value);
}
function pageAiApplyProfileMemory(payload) {
var upstream = pageAiUnwrapUpstream(payload) || {};
pageUiState.pageAiProfileMemory = {
memory: String(upstream.memory || ''),
user: String(upstream.user || ''),
soul: String(upstream.soul || '')
};
pageUiState.pageAiProfileMemoryDrafts = {
memory: pageUiState.pageAiProfileMemory.memory,
user: pageUiState.pageAiProfileMemory.user,
soul: pageUiState.pageAiProfileMemory.soul
};
}
function pageAiFilteredSkillEntries() {
var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase();
return pageAiSkillListEntries().filter(function(skill) {
if (!query) return true;
return String(skill.name || '').toLowerCase().indexOf(query) >= 0
|| String(skill.description || '').toLowerCase().indexOf(query) >= 0
|| String(skill.category || '').toLowerCase().indexOf(query) >= 0;
});
}
async function pageAiLoadProfiles() {
try {
var response = await fetch('/api/hermes/client/profiles', {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'profiles_failed_' + response.status);
var profiles = pageAiNormalizeProfiles(payload);
pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'default', active: true }];
var active = pageAiProfileValue(pageUiState.pageAiProfiles.find(function(profile) { return profile.active; }));
pageAiSetActiveProfile(active || pageAiCurrentProfile());
pageUiState.pageAiProfileError = '';
} catch (error) {
pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error);
if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'default', active: true }];
pageAiSetActiveProfile(pageAiCurrentProfile());
}
renderPageAiProviderButtons();
renderPageAiControls();
}
async function pageAiSwitchProfile(profileName) {
var next = String(profileName || '').trim();
if (!next) return;
pageAiSetActiveProfile(next);
renderPageAiProviderButtons();
renderPageAiControls();
try {
var response = await fetch('/api/hermes/client/profiles/active', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: next })
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'profile_switch_failed_' + response.status);
pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(session) {
return session && session.profile === next;
});
pageUiState.pageAiActiveSessionId = '';
pageUiState.pageAiMessages = [];
pageAiPersistSessions();
await pageAiEnsureHermesSession(true);
await pageAiLoadProfileMemory();
await pageAiLoadSkills();
renderPageAiControls();
} catch (error) {
pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error);
}
renderPageAiConversation();
}
async function pageAiLoadProfileMemory() {
try {
var response = await fetch('/api/hermes/client/profile-memory?profile=' + encodeURIComponent(pageAiCurrentProfile()), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'profile_memory_failed_' + response.status);
pageAiApplyProfileMemory(payload);
pageUiState.pageAiProfileMemoryError = '';
} catch (error) {
pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error);
}
renderPageAiControls();
renderPageAiConversation();
}
async function pageAiSaveProfileMemory(section) {
var normalized = String(section || '').trim();
if (['memory', 'user', 'soul'].indexOf(normalized) < 0) return;
var content = String(pageUiState.pageAiProfileMemoryDrafts[normalized] || '');
try {
var response = await fetch('/api/hermes/client/profile-memory', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
profile: pageAiCurrentProfile(),
section: normalized,
content: content
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'profile_memory_save_failed_' + response.status);
pageUiState.pageAiProfileMemory[normalized] = content;
pageUiState.pageAiProfileMemoryError = '';
document.documentElement.setAttribute('data-mnote-page-ai-memory-saved', normalized);
} catch (error) {
pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error);
}
renderPageAiControls();
renderPageAiConversation();
}
async function pageAiLoadSkills() {
try {
var response = await fetch('/api/hermes/client/skills?profile=' + encodeURIComponent(pageAiCurrentProfile()), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'skills_failed_' + response.status);
pageUiState.pageAiSkills = pageAiNormalizeSkills(payload);
pageUiState.pageAiSkillError = '';
} catch (error) {
pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error);
pageUiState.pageAiSkills = { categories: [], archived: [] };
}
renderPageAiControls();
renderPageAiConversation();
}
async function pageAiToggleSkill(skillName, enabled) {
var name = String(skillName || '').trim();
if (!name) return;
var previous = null;
pageAiSkillListEntries().forEach(function(skill) {
if (skill.name === name && previous == null) previous = skill.enabled !== false;
});
try {
var response = await fetch('/api/hermes/client/skills/toggle', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
profile: pageAiCurrentProfile(),
name: name,
enabled: Boolean(enabled)
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'skill_toggle_failed_' + response.status);
pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) {
pageAiNormalizeArray(category.skills).forEach(function(skill) {
if (skill.name === name) skill.enabled = Boolean(enabled);
});
});
document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', name);
pageUiState.pageAiSkillError = '';
} catch (error) {
pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error);
if (previous != null) {
pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) {
pageAiNormalizeArray(category.skills).forEach(function(skill) {
if (skill.name === name) skill.enabled = previous;
});
});
}
}
renderPageAiControls();
renderPageAiConversation();
}
function renderPageAiProviderButtons() {
var drawer = ensurePageAiDrawer();
drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) {
@@ -3176,6 +3511,103 @@ const SIDEBAR_TREE_JS: &str = r##"
}
}
function renderPageAiControls() {
var drawer = ensurePageAiDrawer();
var activeProfile = pageAiCurrentProfile();
var profileSelect = drawer.querySelector('[data-page-ai-profile-select]');
if (profileSelect instanceof HTMLSelectElement) {
var profiles = pageUiState.pageAiProfiles.length ? pageUiState.pageAiProfiles : [{ name: activeProfile, active: true }];
profileSelect.innerHTML = profiles.map(function(profile) {
var name = pageAiProfileValue(profile) || 'default';
var label = profile.alias ? name + ' · ' + profile.alias : name;
return '<option value="' + escapeHtml(name) + '"' + (name === activeProfile ? ' selected' : '') + '>' + escapeHtml(label) + '</option>';
}).join('');
profileSelect.value = activeProfile;
}
var runStatus = drawer.querySelector('[data-page-ai-run-status]');
if (runStatus instanceof HTMLElement) runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus);
var sessionNode = drawer.querySelector('[data-page-ai-session-status]');
if (sessionNode instanceof HTMLElement) {
var session = pageAiCurrentSession();
sessionNode.textContent = session && session.id ? String(session.title || '当前页问答') : '等待 Hermes session';
}
var scopeSelect = drawer.querySelector('[data-page-ai-context-scope]');
if (scopeSelect instanceof HTMLSelectElement) scopeSelect.value = pageUiState.pageAiContextScope;
var scopeLabel = drawer.querySelector('[data-page-ai-context-scope-label]');
if (scopeLabel instanceof HTMLElement) scopeLabel.textContent = pageAiContextScopeLabel(pageUiState.pageAiContextScope);
drawer.querySelectorAll('[data-page-ai-tab]').forEach(function(button) {
var target = button.getAttribute('data-page-ai-tab') || 'chat';
var active = target === pageUiState.pageAiPage;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
drawer.querySelectorAll('[data-page-ai-panel]').forEach(function(panel) {
if (panel instanceof HTMLElement) {
panel.hidden = panel.getAttribute('data-page-ai-panel') !== pageUiState.pageAiPage;
}
});
var profileError = drawer.querySelector('[data-page-ai-profile-error]');
if (profileError instanceof HTMLElement) {
profileError.textContent = pageUiState.pageAiProfileError || '';
profileError.hidden = !pageUiState.pageAiProfileError;
}
var memoryError = drawer.querySelector('[data-page-ai-memory-error]');
if (memoryError instanceof HTMLElement) {
memoryError.textContent = pageUiState.pageAiProfileMemoryError || '';
memoryError.hidden = !pageUiState.pageAiProfileMemoryError;
}
var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]');
if (agentPanel instanceof HTMLElement) {
agentPanel.innerHTML = ['soul', 'user', 'memory'].map(function(section) {
var label = pageAiMemoryFileLabel(section);
var value = pageUiState.pageAiProfileMemoryDrafts[section] || '';
return '' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head">' +
'<div>' +
'<div class="wolai-page-ai-memory-title">' + escapeHtml(label) + '</div>' +
'<div class="wolai-page-ai-memory-scope">保存到 Hermes profile: ' + escapeHtml(activeProfile) + '</div>' +
'</div>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-memory-save="' + escapeHtml(section) + '">保存</button>' +
'</div>' +
'<textarea class="wolai-page-ai-memory-editor" data-page-ai-memory-editor="' + escapeHtml(section) + '" spellcheck="false">' + escapeHtml(value) + '</textarea>' +
'</section>';
}).join('');
}
var skillError = drawer.querySelector('[data-page-ai-skill-error]');
if (skillError instanceof HTMLElement) {
skillError.textContent = pageUiState.pageAiSkillError || '';
skillError.hidden = !pageUiState.pageAiSkillError;
}
var skillSearch = drawer.querySelector('[data-page-ai-skill-search]');
if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) {
skillSearch.value = pageUiState.pageAiSkillQuery;
}
var skillList = drawer.querySelector('[data-page-ai-skill-list]');
if (skillList instanceof HTMLElement) {
var skills = pageAiFilteredSkillEntries();
if (!skills.length) {
skillList.innerHTML = '<div class="wolai-page-ai-empty">没有匹配的 Hermes skill。</div>';
} else {
skillList.innerHTML = skills.map(function(skill) {
var source = skill.source || 'local';
var sourceText = source + (skill.modified ? ' · modified' : '');
return '' +
'<div class="wolai-page-ai-skill-row">' +
'<div class="wolai-page-ai-skill-copy">' +
'<div class="wolai-page-ai-skill-name">' + escapeHtml(skill.name) + '</div>' +
'<div class="wolai-page-ai-skill-desc">' + escapeHtml(skill.description || '无描述') + '</div>' +
'<div class="wolai-page-ai-skill-source">' + escapeHtml(sourceText) + '</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') + '">' +
'<span></span>' +
'</button>' +
'</div>';
}).join('');
}
}
}
function humanizePageAiResponse(rawText, promptText) {
var providerLabel = pageAiProviderLabel(pageUiState.pageAiProvider);
var text = String(rawText || '').trim();
@@ -3210,32 +3642,85 @@ const SIDEBAR_TREE_JS: &str = r##"
'<div class="wolai-page-ai-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-ai-header">' +
'<div class="wolai-page-ai-header-copy">' +
'<h2 class="wolai-page-ai-title" data-title="page-ai-title">智能问答</h2>' +
'<h2 class="wolai-page-ai-title" data-title="page-ai-title">页面 AI</h2>' +
'<div class="wolai-page-ai-subtitle">当前页面上下文优先 · Hermes</div>' +
'</div>' +
'<button type="button" class="wolai-surface-close" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
'</div>' +
'<div class="wolai-page-ai-body">' +
'<div class="wolai-page-ai-suggestions">' +
'<div class="wolai-page-ai-suggestions-header">' +
'<span>推荐问题</span>' +
'<div class="wolai-page-ai-intents">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-summary">创建 Summary</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-ai-note">创建 AI Note</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
'<div class="wolai-page-ai-statusbar">' +
'<div class="wolai-page-ai-statusitem">' +
'<span class="wolai-page-ai-statuslabel">会话</span>' +
'<span class="wolai-page-ai-statusvalue" data-page-ai-session-status>等待 Hermes session</span>' +
'</div>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
'<div class="wolai-page-ai-statusitem">' +
'<span class="wolai-page-ai-statuslabel">状态</span>' +
'<span class="wolai-page-ai-statusvalue" data-page-ai-run-status>空闲</span>' +
'</div>' +
'<div class="wolai-page-ai-statusitem">' +
'<span class="wolai-page-ai-statuslabel">上下文</span>' +
'<span class="wolai-page-ai-statusvalue" data-page-ai-context-scope-label>当前页</span>' +
'</div>' +
'<a class="wolai-page-ai-settings-link" href="/auth" target="_blank" rel="noreferrer">Hermes 设置</a>' +
'</div>' +
'<div class="wolai-page-ai-topbar">' +
'<label class="wolai-page-ai-profile-select">' +
'<span>agent / profile</span>' +
'<select data-page-ai-profile-select></select>' +
'</label>' +
'<label class="wolai-page-ai-context-select">' +
'<span>上下文</span>' +
'<select data-page-ai-context-scope>' +
'<option value="page">当前页</option>' +
'<option value="selection">当前选区</option>' +
'<option value="block">当前块</option>' +
'<option value="options">页面设置</option>' +
'</select>' +
'</label>' +
'</div>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-profile-error hidden></div>' +
'<div class="wolai-page-ai-body">' +
'<div class="wolai-page-ai-tabs" role="tablist" aria-label="页面 AI 面板">' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="chat" role="tab" aria-selected="true">chat</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">agent</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="skills" role="tab" aria-selected="false">skills</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="history" role="tab" aria-selected="false">history</button>' +
'</div>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="chat">' +
'<div class="wolai-page-ai-suggestions">' +
'<div class="wolai-page-ai-suggestions-header">' +
'<span>推荐问题</span>' +
'<div class="wolai-page-ai-intents">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-summary">创建 Summary</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-intent="create-ai-note">创建 AI Note</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate">换一换</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
'</div>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="agent" hidden>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-memory-error hidden></div>' +
'<div class="wolai-page-ai-memory-grid" data-page-ai-agent-panel></div>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="skills" hidden>' +
'<div class="wolai-page-ai-skills-toolbar">' +
'<label class="wolai-page-ai-skill-search">' +
'<span>搜索技能</span>' +
'<input type="search" data-page-ai-skill-search placeholder="搜索技能…" />' +
'</label>' +
'</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>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="history" hidden>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
'</section>' +
'</div>' +
'<div class="wolai-page-ai-footer">' +
'<div class="wolai-page-ai-toolbar">' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话">新会话</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history">历史会话</button>' +
'<div class="wolai-page-ai-provider-group">' +
'<button type="button" class="wolai-page-ai-model-chip is-active" data-page-ai-provider="hermes" aria-pressed="true">Hermes</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-input-row">' +
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
@@ -3249,7 +3734,7 @@ const SIDEBAR_TREE_JS: &str = r##"
function renderPageAiSuggestions() {
var drawer = ensurePageAiDrawer();
var list = drawer.querySelector('[data-page-ai-suggestion-list]');
var list = drawer.querySelector('[data-page-ai-panel="chat"] [data-page-ai-suggestion-list]');
if (!(list instanceof HTMLElement)) return;
var suggestions = pageAiSuggestions();
var offset = pageUiState.pageAiSuggestionIndex % suggestions.length;
@@ -3261,7 +3746,7 @@ const SIDEBAR_TREE_JS: &str = r##"
function renderPageAiConversation() {
var drawer = ensurePageAiDrawer();
var conversation = drawer.querySelector('[data-page-ai-conversation]');
var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]');
if (!(conversation instanceof HTMLElement)) return;
if (pageUiState.pageAiPage === 'history') {
if (!pageUiState.pageAiSessions.length) {
@@ -3301,18 +3786,30 @@ const SIDEBAR_TREE_JS: &str = r##"
renderPageAiSuggestions();
renderPageAiConversation();
renderPageAiProviderButtons();
renderPageAiControls();
var drawer = ensurePageAiDrawer();
drawer.hidden = false;
pageUiState.pageAiOpen = true;
updatePageAiTriggerState();
pageAiEnsureHermesSession().then(function() {
Promise.all([
pageAiLoadProfiles(),
pageAiLoadProfileMemory(),
pageAiLoadSkills()
]).then(function() {
renderPageAiControls();
}).catch(function() {}).then(function() {
return pageAiEnsureHermesSession();
}).then(function() {
return pageAiRestoreHermesSession();
}).then(function() {
renderPageAiControls();
}).catch(function(error) {
pageUiState.pageAiMessages.push({
role: 'assistant',
content: 'Hermes 当前不可用:' + (error instanceof Error ? error.message : String(error))
});
renderPageAiConversation();
renderPageAiControls();
});
}
@@ -3366,6 +3863,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var currentSession = null;
try {
await pageAiEnsureHermesSession();
pageAiSetRunStatus('queued');
var contextSnapshot = currentPageAiContextSnapshot();
var aggregate = contextSnapshot.aggregate || {};
var body = contextSnapshot.body || {};
@@ -3388,6 +3886,7 @@ const SIDEBAR_TREE_JS: &str = r##"
workspaceId: resolveWorkspaceId(document.body),
documentId: currentDocumentId(),
sessionId: pageUiState.pageAiActiveSessionId,
profile: pageAiCurrentProfile(),
message: prompt,
model: 'hermes-agent',
pageContext: {
@@ -3415,6 +3914,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload;
var runId = upstream && (upstream.run_id || upstream.runId);
if (!runId) throw new Error('hermes_run_missing_run_id');
pageAiSetRunStatus('running', runId);
var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), {
headers: { 'accept': 'text/event-stream' }
});
@@ -3437,6 +3937,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var completed = JSON.parse(payloadText || 'null');
if (completed && completed.output) assistantText = searchText(completed.output);
} catch (_) {}
pageAiSetRunStatus('completed', runId);
}
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
try {
@@ -3449,6 +3950,7 @@ const SIDEBAR_TREE_JS: &str = r##"
pageUiState.pageAiMessages.push({ role: 'tool', content: eventName });
}
renderPageAiConversation();
pageAiSetRunStatus('tool_calling', runId);
}
});
pageUiState.pageAiMessages.push({
@@ -3461,6 +3963,7 @@ const SIDEBAR_TREE_JS: &str = r##"
currentSession.updatedAt = Date.now();
}
} catch (error) {
pageAiSetRunStatus('failed');
pageUiState.pageAiMessages.push({
role: 'assistant',
content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error))
@@ -3473,6 +3976,7 @@ const SIDEBAR_TREE_JS: &str = r##"
} finally {
pageUiState.pageAiBusy = false;
renderPageAiConversation();
renderPageAiControls();
}
}
@@ -4039,6 +4543,15 @@ const SIDEBAR_TREE_JS: &str = r##"
}
}
var pageAiTab = closestAction(e.target, '[data-page-ai-tab]');
if (pageAiTab) {
e.preventDefault();
pageUiState.pageAiPage = pageAiTab.getAttribute('data-page-ai-tab') || 'chat';
renderPageAiControls();
renderPageAiConversation();
return;
}
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
if (pageAiProvider) {
e.preventDefault();
@@ -4047,6 +4560,22 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
var pageAiMemorySave = closestAction(e.target, '[data-page-ai-memory-save]');
if (pageAiMemorySave) {
e.preventDefault();
void pageAiSaveProfileMemory(pageAiMemorySave.getAttribute('data-page-ai-memory-save') || '');
return;
}
var pageAiSkillToggle = closestAction(e.target, '[data-page-ai-skill-toggle]');
if (pageAiSkillToggle) {
e.preventDefault();
var skillName = pageAiSkillToggle.getAttribute('data-page-ai-skill-toggle') || '';
var nextEnabled = pageAiSkillToggle.getAttribute('aria-pressed') !== 'true';
void pageAiToggleSkill(skillName, nextEnabled);
return;
}
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
if (pageAiSession) {
e.preventDefault();
@@ -4079,6 +4608,7 @@ const SIDEBAR_TREE_JS: &str = r##"
e.preventDefault();
pageAiLoadSessions();
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
renderPageAiControls();
renderPageAiConversation();
return;
}
@@ -4281,7 +4811,34 @@ const SIDEBAR_TREE_JS: &str = r##"
}
});
document.addEventListener('input', function(event) {
var skillSearch = closestAction(event.target, '[data-page-ai-skill-search]');
if (skillSearch instanceof HTMLInputElement) {
pageUiState.pageAiSkillQuery = skillSearch.value;
renderPageAiControls();
return;
}
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
if (memoryEditor instanceof HTMLTextAreaElement) {
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
if (['memory', 'user', 'soul'].indexOf(section) >= 0) {
pageUiState.pageAiProfileMemoryDrafts[section] = memoryEditor.value;
}
}
});
document.addEventListener('change', function(event) {
var pageAiProfileSelect = closestAction(event.target, '[data-page-ai-profile-select]');
if (pageAiProfileSelect instanceof HTMLSelectElement) {
void pageAiSwitchProfile(pageAiProfileSelect.value);
return;
}
var pageAiContextSelect = closestAction(event.target, '[data-page-ai-context-scope]');
if (pageAiContextSelect instanceof HTMLSelectElement) {
pageAiSetContextScope(pageAiContextSelect.value);
renderPageAiControls();
return;
}
var globalCheckbox = closestAction(event.target, '[data-global-option-checkbox="showHeadingNumbers"]');
if (globalCheckbox instanceof HTMLInputElement) {
writeGlobalShowHeadingNumbers(globalCheckbox.checked);
+231
View File
@@ -2668,6 +2668,95 @@ body {
gap: 4px;
}
.wolai-page-ai-statusbar {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(68px, auto) minmax(72px, auto) auto;
gap: 8px;
align-items: stretch;
}
.wolai-page-ai-statusitem {
min-width: 0;
padding: 8px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
background: #FAFAFA;
}
.wolai-page-ai-statuslabel,
.wolai-page-ai-profile-select span,
.wolai-page-ai-context-select span,
.wolai-page-ai-skill-search span,
.wolai-page-ai-memory-scope,
.wolai-page-ai-skill-source {
display: block;
color: #8B8782;
font-size: 11px;
line-height: 16px;
}
.wolai-page-ai-statusvalue {
display: block;
min-width: 0;
overflow: hidden;
color: #1B1C1C;
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-settings-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
color: #1B1C1C;
font-size: 12px;
text-decoration: none;
}
.wolai-page-ai-topbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 8px;
}
.wolai-page-ai-profile-select,
.wolai-page-ai-context-select,
.wolai-page-ai-skill-search {
display: grid;
gap: 4px;
min-width: 0;
}
.wolai-page-ai-profile-select select,
.wolai-page-ai-context-select select,
.wolai-page-ai-skill-search input {
width: 100%;
min-width: 0;
height: 34px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 8px;
background: #FFF;
color: #1B1C1C;
font-size: 12px;
}
.wolai-page-ai-inline-error {
padding: 8px 10px;
border: 1px solid rgba(198, 80, 80, 0.18);
border-radius: 8px;
background: #FFF5F5;
color: #9F2D2D;
font-size: 12px;
line-height: 18px;
}
.wolai-page-ai-body {
display: flex;
min-height: 0;
@@ -2676,6 +2765,24 @@ body {
gap: 16px;
}
.wolai-page-ai-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.wolai-page-ai-panel-section {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
}
.wolai-page-ai-panel-section[hidden] {
display: none !important;
}
.wolai-page-ai-suggestions {
display: flex;
flex-direction: column;
@@ -2731,6 +2838,122 @@ body {
overflow: auto;
}
.wolai-page-ai-memory-grid {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
overflow: auto;
}
.wolai-page-ai-memory-card {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
background: #FFF;
}
.wolai-page-ai-memory-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.wolai-page-ai-memory-title,
.wolai-page-ai-skill-name {
color: #1B1C1C;
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.wolai-page-ai-memory-editor {
width: 100%;
min-height: 118px;
padding: 10px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 8px;
background: #FAFAFA;
color: #1B1C1C;
font-size: 12px;
line-height: 18px;
resize: vertical;
}
.wolai-page-ai-skills-toolbar {
display: flex;
gap: 8px;
}
.wolai-page-ai-skill-list {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
overflow: auto;
}
.wolai-page-ai-skill-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 10px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
background: #FFF;
}
.wolai-page-ai-skill-copy {
min-width: 0;
}
.wolai-page-ai-skill-desc {
display: -webkit-box;
overflow: hidden;
color: #5A5A5A;
font-size: 12px;
line-height: 18px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.wolai-page-ai-skill-switch {
position: relative;
width: 36px;
height: 22px;
flex: 0 0 36px;
border: 0;
border-radius: 999px;
background: #D8D6D1;
cursor: pointer;
}
.wolai-page-ai-skill-switch span {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #FFF;
box-shadow: 0 1px 3px rgba(27, 28, 28, 0.16);
transition: transform 0.16s ease;
}
.wolai-page-ai-skill-switch.is-on {
background: #F97316;
}
.wolai-page-ai-skill-switch.is-on span {
transform: translateX(14px);
}
.wolai-page-ai-message {
display: flex;
flex-direction: column;
@@ -2828,6 +3051,14 @@ body {
width: min(100vw - 24px, 420px);
}
.wolai-page-ai-statusbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wolai-page-ai-topbar {
grid-template-columns: minmax(0, 1fr);
}
.wolai-public-pill,
.wolai-breadcrumb-root,
.wolai-breadcrumb-separator {
@@ -0,0 +1,234 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
renameDocument,
} = require("./tree-shell-smoke-helpers");
async function main() {
const suffix = Date.now().toString(36);
const title = `TEST-HERMES-AI-agent-skill-${suffix}`;
const runId = `run_agent_skill_${suffix}`;
const createdIds = [];
const sessionBodies = [];
const runBodies = [];
const activeProfileBodies = [];
const memorySaveBodies = [];
const skillToggleBodies = [];
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
try {
await page.route("**/api/ai-agent/run", async (route) => {
throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
});
await page.route("**/api/hermes/client/profiles/active", async (route) => {
activeProfileBodies.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, profile: { name: "chemist", active: true } }),
});
});
await page.route("**/api/hermes/client/profiles", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
profiles: [
{ name: "default", active: true, model: "gpt-5", gateway: "openai", alias: "Default" },
{ name: "chemist", active: false, model: "gpt-5", gateway: "openai", alias: "Chemist" },
],
}),
});
});
await page.route("**/api/hermes/client/profile-memory**", async (route) => {
if (route.request().method() === "POST") {
memorySaveBodies.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
return;
}
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
memory: "长期记忆",
user: "用户偏好",
soul: "原始人格",
memory_mtime: Date.now(),
user_mtime: Date.now(),
soul_mtime: Date.now(),
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
categories: [
{
name: "writing",
description: "Writing helpers",
skills: [
{
name: "ai-writing-detection",
description: "检测 AI 写作痕迹",
enabled: false,
source: "local",
},
],
},
],
archived: [],
}),
});
});
await page.route("**/api/hermes/client/skills/toggle", async (route) => {
skillToggleBodies.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
const body = JSON.parse(route.request().postData() || "{}");
sessionBodies.push(body);
const profile = body.profile || "default";
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
sessionId: `mnote_${profile}_${suffix}`,
title: "当前页问答",
profile,
traceId: `trace_session_${suffix}`,
persistence: "hermes_on_first_run",
}),
});
});
await page.route("**/api/hermes/client/sessions/**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
session: {
sessionId: `mnote_chemist_${suffix}`,
profile: "chemist",
messages: [],
},
}),
});
});
await page.route("**/api/hermes/client/runs", async (route) => {
runBodies.push(JSON.parse(route.request().postData() || "{}"));
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, runId, sessionId: `mnote_chemist_${suffix}` }),
});
});
await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: `mnote_chemist_${suffix}`, delta: "Agent " })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: `mnote_chemist_${suffix}`, output: "Agent response" })}\n\n`,
});
});
await ensureAuthenticated(page, context.request);
const target = await createTempDocument(context.request);
createdIds.push(target.documentId);
await renameDocument(context.request, target.workspaceId, target.documentId, title);
await page.goto(
`${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
);
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[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-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-memory-editor="soul"]').fill(`SOUL-${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-memory-save="soul"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-page-ai-memory-saved") === "soul", null, {
timeout: UI_TIMEOUT_MS,
});
await page.locator('[data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-skill-search]").fill("writing", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-toggle="ai-writing-detection"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "ai-writing-detection",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill(`请使用 chemist agent 总结 ${title}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Agent response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
assert(activeProfileBodies.some((body) => body.name === "chemist"), "选择 agent/profile 必须调用 Hermes active profile API");
assert(memorySaveBodies.some((body) => body.section === "soul" && body.content === `SOUL-${suffix}`), "SOUL.md 保存必须调用 Hermes memory API");
assert(
skillToggleBodies.some((body) => body.name === "ai-writing-detection" && body.enabled === true),
"skill 启用必须调用 Hermes skills toggle API",
);
assert(runBodies.some((body) => body.profile === "chemist"), "Hermes run 必须携带当前 agent/profile");
assert(sessionBodies.some((body) => body.profile === "chemist"), "切换 agent 后新 Hermes session 必须携带 profile");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: target.documentId,
workspaceId: target.workspaceId,
profile: "chemist",
memorySectionsSaved: memorySaveBodies.map((body) => body.section),
skillsToggled: skillToggleBodies.map((body) => body.name),
runProfiles: runBodies.map((body) => body.profile),
},
null,
2,
),
);
} finally {
await cleanupDocuments(context.request, createdIds).catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});