From f257b2a01491e5fe78e2b99f15c76cc8aa7c78ec Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Thu, 14 May 2026 17:04:17 +0800 Subject: [PATCH] feat: expand page ai hermes control surface --- ...-page-ai-mini-hermes-control-surface-v1.md | 157 ++++- .../mnote-web/src/routes/hermes_client.rs | 250 ++++++++ rust/crates/mnote-web/src/routes/mod.rs | 24 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 601 +++++++++++++++++- rust/crates/mnote-web/src/ssr/styles.rs | 231 +++++++ .../task-hermes-page-ai-agent-skill-smoke.js | 234 +++++++ 6 files changed, 1447 insertions(+), 50 deletions(-) create mode 100644 scripts/task-hermes-page-ai-agent-skill-smoke.js diff --git a/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md b/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md index 0b220c28..2074a35b 100644 --- a/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md +++ b/design/07-ai/process/7-7-page-ai-mini-hermes-control-surface-v1.md @@ -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 profile;profile 等同本设计中的 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 后创建新 session,session 的 `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 来自 Hermes;page 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 profile;mnote 不直接写 `.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 smoke:session 状态条。 - 验收标准:断言 session title/model/run status 可见。 -- [ ] I2. 新增或扩展 browser smoke:run stop/retry。 +- [ ] I2. 新增或扩展 browser smoke:agent/profile 选择。 + - 验收标准:断言 profile 列表来自 Hermes;切换 profile 后新 run/session 使用目标 profile。 +- [ ] I3. 新增或扩展 browser smoke:agent 人格文件编辑。 + - 验收标准:保存 `SOUL.md` 或 `USER.md` 后通过 Hermes API 读回一致;运行中保存行为符合禁用或提示规则。 +- [ ] I4. 新增或扩展 browser smoke:skill 启停。 + - 验收标准:toggle 后刷新仍保持 Hermes 返回状态;失败时 UI 回滚。 +- [ ] I5. 新增或扩展 browser smoke:run stop/retry。 - 验收标准:至少覆盖 stop;retry 若未实现则断言按钮不存在或禁用。 -- [ ] I3. 新增或扩展 browser smoke:context scope。 +- [ ] I6. 新增或扩展 browser smoke:context scope。 - 验收标准:选区/当前页上下文能进入 run payload 或 tool audit。 -- [ ] I4. 新增或扩展 browser smoke:tool 可用性与 audit。 +- [ ] I7. 新增或扩展 browser smoke:tool 可用性与 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 自己的管理台。 diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index b5136d75..5a8a2c5b 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -22,6 +22,7 @@ pub struct CreateSessionRequest { document_id: Option, trace_id: Option, title: Option, + profile: Option, } 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, +) -> Result<(StatusCode, HeaderMap, Json), 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, + Path(profile_name): Path, +) -> Result<(StatusCode, HeaderMap, Json), 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, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), 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, +) -> Result<(StatusCode, HeaderMap, Json), 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, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), 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, + Query(query): Query>, +) -> Result<(StatusCode, HeaderMap, Json), 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::>() + .join("&"); + path.push('?'); + path.push_str(¶ms); + } + proxy_json(&context, reqwest::Method::GET, &upstream, &path, None).await +} + +pub async fn toggle_skill( + Extension(context): Extension, + Json(payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), 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, Path(session_id): Path, @@ -335,10 +470,16 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result Result 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( diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index fe9da5a6..79965075 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -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 ''; + }).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 '' + + '
' + + '
' + + '
' + + '
' + escapeHtml(label) + '
' + + '
保存到 Hermes profile: ' + escapeHtml(activeProfile) + '
' + + '
' + + '' + + '
' + + '' + + '
'; + }).join(''); + } + var skillError = drawer.querySelector('[data-page-ai-skill-error]'); + if (skillError instanceof HTMLElement) { + skillError.textContent = pageUiState.pageAiSkillError || ''; + skillError.hidden = !pageUiState.pageAiSkillError; + } + var 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 = '
没有匹配的 Hermes skill。
'; + } else { + skillList.innerHTML = skills.map(function(skill) { + var source = skill.source || 'local'; + var sourceText = source + (skill.modified ? ' · modified' : ''); + return '' + + '
' + + '
' + + '
' + escapeHtml(skill.name) + '
' + + '
' + escapeHtml(skill.description || '无描述') + '
' + + '
' + escapeHtml(sourceText) + '
' + + '
' + + '' + + '
'; + }).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##" '