diff --git a/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md b/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md new file mode 100644 index 00000000..7fedbc59 --- /dev/null +++ b/design/07-ai/done/7-8-page-ai-hermes-runtime-bff-next-v1.md @@ -0,0 +1,404 @@ +# 7-8 [process] 页面 AI Hermes Runtime BFF 下一阶段设计 v1 + +> 更新时间:2026-05-15 +> +> 当前状态:`PROCESS`。本文承接 `7-3`、`7-4`、`7-5`、`7-6`、`7-7` 已完成的 Hermes 页面内客户端主线,写入对 `hermes-web-ui-0.5.18` Chat Runtime 架构的吸收结论。 +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-4-page-ai-hermes-panel-execution-checklist-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/done/7-7-page-ai-mini-hermes-control-surface-v1.md` + +--- + +## 1. 结论 + +`hermes-web-ui-0.5.18` 对 mnote 有用,但不能原样照搬。 + +可吸收的是: + +- Web 端应采用 `Browser -> BFF -> Hermes gateway`,不是浏览器直接 ACP。 +- BFF 应承担 runtime 管理:active run registry、resume、queue、abort、event normalization、gateway manager、错误规整。 +- 前端只展示 message、tool event、状态和输入,不持有 AI 编排真相。 + +不可吸收的是: + +- 不把 mnote 的页面 AI 聊天真相改为 Convex、SQLite 或 mnote 自建 chat DB。 +- 不把 Hermes 降级成 `/v1/responses store=false` 的纯模型执行器。 +- 不让 mnote-web 根据自有数据库长期组装完整 `conversation_history`。 + +当前冻结方向: + +> **mnote 学 `hermes-web-ui` 的 BFF/runtime 架构思想,但会话真相继续归 Hermes;mnote 只保存业务事实、audit、artifact、edge、page/body/title/options 结果。** + +--- + +## 2. 当前实现定位 + +当前页面 AI 接入是: + +```text +Leptos 页面 AI 面板 + -> mnote-web /api/hermes/client/* + -> Hermes gateway /v1/runs + -> Hermes gateway /v1/runs/{run_id}/events +``` + +同时: + +- `profiles / skills / memory` 等设置能力由 `mnote-web` 本地 BFF 通过 Hermes CLI 或 `~/.hermes` 文件读写补齐。 +- `mnote.*` 工具由 mnote Rust runtime 承接,Hermes 只通过 skill/plugin/tool 调用。 +- 页面 AI 不再走旧 `/api/ai-agent/run`。 + +这已经满足最小闭环,但仍只是“最小 runtime 接入”,缺少 `hermes-web-ui` 已经具备的完整 Chat Runtime 能力。 + +--- + +## 3. ACP、gateway、responses 的取舍 + +### 3.1 ACP + +ACP 适合: + +- VSCode 插件 +- Tauri / Electron 桌面端 +- 本地 IDE host +- 需要由宿主启动 `hermes acp` 并通过 stdio JSON-RPC 深度交互的场景 + +ACP 不适合作为 mnote 页面 AI 第一主链: + +- 浏览器不能直接 `spawn hermes acp`。 +- 若 Web 使用 ACP,仍需 mnote-web 作为 ACP bridge 管理 stdio 子进程。 +- 这会引入长期进程、session、backpressure、并发、恢复和崩溃清理复杂度。 +- 当前 Hermes gateway 已提供 Web 更自然的 `/v1/*` 与 stream 能力。 + +结论: + +> **ACP 后续只作为桌面端或高级 host bridge 候选,不作为页面 AI Web 主链。** + +### 3.2 `/v1/runs` + +`/v1/runs` 适合当前 mnote 主线: + +- Hermes 持有 run / session / event 语义。 +- mnote 页面 AI 只是 Hermes client。 +- mnote 不需要长期组装 `conversation_history`。 +- 与“AI 会话归 Hermes,mnote 能力归 Rust kernel”一致。 + +风险: + +- mnote-web 需要补 runtime 状态管理,否则刷新、断线、队列、abort 体验弱。 +- Hermes gateway 的非 `/v1/*` 管理面不足,需要 BFF 补 profiles/skills/memory/gateway manager。 + +结论: + +> **短期继续以 `/v1/runs` 为默认页面 AI 主链,并在 mnote-web 上补 Runtime BFF。** + +### 3.3 `/v1/responses` + +`hermes-web-ui` 的产品化 Chat Runtime 使用 `/v1/responses stream=true`,典型形态是: + +```text +Browser Vue UI + -> Socket.IO /chat-run + -> hermes-web-ui server + -> /v1/responses stream=true + -> server 从本地 DB 组装 conversation_history + -> server 把 response.* 事件转成 run/message/tool 事件 + -> server 写入 SQLite session/messages +``` + +它适合“自建 Chat Runtime”: + +- 后端自有 session/messages 数据库。 +- 后端自行压缩上下文。 +- 后端自行映射 tool event。 +- `store=false` 时 Hermes 不再是会话真相。 + +这与 mnote 当前页面 AI 方向有冲突。除非后续明确改变设计,把“AI 会话真相”从 Hermes 迁到 mnote,否则不能把 `/v1/responses store=false + mnote conversation_history` 写成默认主链。 + +允许使用 `/v1/responses` 的条件: + +- 只作为特定 runtime 实验或 fallback。 +- 必须证明 Hermes session 仍能作为用户可见会话真相,或者另开设计稿明确改变会话真相。 +- 不得把 mnote page aggregate / Convex 页面数据变成聊天消息数据库。 + +结论: + +> **`/v1/responses` 先作为参考实现和可评估候选,不作为当前页面 AI 默认主链。** + +--- + +## 4. 目标架构 + +下一阶段目标不是改成 ACP,也不是直接改成 `/v1/responses`,而是在当前 gateway 主线上补一个 mnote-web Runtime BFF: + +```text +Leptos 页面 AI 面板 + -> mnote-web Hermes Runtime BFF + - active run registry + - session/run resume + - queue + - abort + - event normalization + - gateway/profile/skill/memory BFF + - trace/audit 串联 + -> Hermes gateway /v1/runs + -> Hermes session storage + -> mnote skill/plugin/tool + -> Rust runtime / kernel +``` + +真相边界: + +- Hermes:AI session、message、run、tool event、usage、model、profile。 +- mnote:页面、树、正文、artifact、edge、projection、audit、权限、idempotency。 +- mnote-web Runtime BFF:运行时连接和事件编排,不保存长期聊天真相。 + +--- + +## 5. Hermes Web UI 参考索引 + +参考根目录: + +```text +/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18 +``` + +只参考下表,不整站搬运: + +| 参考主题 | 文件 | 搜索点 | mnote 中吸收什么 | mnote 中不采用什么 | +| --- | --- | --- | --- | --- | +| Socket runtime 主链 | `packages/server/src/services/hermes/chat-run-socket.ts` | `ChatRunSocket`、`onConnection`、`resumeSession`、`handleRun`、`dequeueNextQueuedRun`、`handleAbort` | active run registry、resume、queue、abort、前端刷新不丢运行态 | 不照搬 Socket.IO;mnote 可用 SSE / WebSocket / HTTP 状态轮询 | +| `/v1/responses` 事件映射 | `packages/server/src/services/hermes/chat-run-socket.ts` | `/v1/responses`、`applyResponseStreamEvent`、`response.output_text.delta`、`response.output_item.done`、`response.completed` | 学习如何把上游事件标准化为 `message.delta`、`tool.started`、`tool.completed`、`run.completed` | 不把 `/v1/responses store=false` 写成默认主链 | +| 后端 session store | `packages/server/src/db/hermes/session-store.ts` | `HermesSessionRow`、`HermesMessageRow`、`createSession`、`addMessage`、`getSessionDetail` | 理解 Hermes Web UI 如何建完整 chat DB,以及字段形态 | 不在 mnote 建第二套 chat DB;不把 Convex 当 AI 会话真相 | +| 前端 chat store | `packages/client/src/stores/hermes/chat.ts` | `resumeServerWorkingRun`、`queueLengths`、`abortState`、`case 'message.delta'`、`case 'tool.started'`、`case 'run.completed'` | 前端状态机、队列提示、abort UI、tool event 归并 | 不复用 Vue/Pinia;不把前端 state 当真相 | +| 前端 API | `packages/client/src/api/hermes/chat.ts` | `startRunViaSocket`、`resumeSession`、`registerSessionHandlers`、`RunEvent` | 客户端事件合同与 runtime event 类型 | 不让浏览器绕过 mnote-web 直连 Hermes | +| VSCode ACP tool UI | `design/05-editor-mainline/reference-code/hermes-vscode-main/src/sessionManager.ts`、`src/protocol.ts`、`src/chatPanel.ts`、`src/webview/main.ts` | `tool_call`、`tool_call_update`、`parseToolCall`、`parseToolCallUpdate`、`case 'toolCall'`、`data-tool-id` | 学习按 `toolCallId` 追加工具行、再用后续 update 原地更新状态的 UI 模型 | 不采用 ACP 作为 Web 主链;只参考 tool event UI 状态机 | +| gateway 管理 | `packages/server/src/services/hermes/gateway-manager.ts`、`packages/server/src/services/gateway-bootstrap.ts` | `getUpstream`、`start`、`stop`、`health` | profile 对应 gateway 的发现、启动、健康检查、错误提示 | 不让页面 AI 抽屉变成完整 gateway 管理台 | +| proxy handler | `packages/server/src/routes/hermes/proxy-handler.ts` | `resolveUpstream`、`SSE_EVENTS_PATH`、`streamSSE`、`/v1/runs` | 路由改写、SSE 拦截、upstream 错误规整 | 不把全量 proxy catch-all 暴露给浏览器 | +| skills 来源 | `packages/server/src/controllers/hermes/skills.ts` | `.bundled_manifest`、`.hub/lock.json`、`.usage.json`、`source`、`modified` | skill 来源与生成/安装/本地分类 | 不复制完整 skill 管理台 | + +快速定位命令: + +```bash +cd /mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-web-ui-0.5.18 +rg -n "ChatRunSocket|resumeSession|handleRun|dequeueNextQueuedRun|handleAbort|/v1/responses|applyResponseStreamEvent|response\\.output_text\\.delta|response\\.output_item\\.done|response\\.completed|HermesSessionRow|startRunViaSocket|gateway-manager" packages/server/src packages/client/src +``` + +--- + +## 6. RuntimeEvent 合同 + +mnote-web BFF 对前端暴露的事件应稳定为 `mnote.hermes_runtime_event.v1`。 + +最小事件集: + +```json +{ "event": "run.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "traceId": "trace_1" } +``` + +```json +{ "event": "message.delta", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "delta": "当前页", "traceId": "trace_1" } +``` + +```json +{ "event": "tool.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "toolCallId": "call_1", "toolName": "mnote.page.get", "traceId": "trace_1" } +``` + +```json +{ "event": "tool.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "toolCallId": "call_1", "toolName": "mnote.page.get", "summary": "读取当前页面", "auditId": "audit_1", "traceId": "trace_1" } +``` + +```json +{ "event": "run.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "output": "总结文本", "usage": { "inputTokens": 0, "outputTokens": 0 }, "traceId": "trace_1" } +``` + +```json +{ "event": "run.failed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "code": "hermes_client_upstream_error", "message": "Hermes upstream 返回错误", "traceId": "trace_1" } +``` + +```json +{ "event": "abort.started", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "traceId": "trace_1" } +``` + +```json +{ "event": "abort.completed", "sessionId": "mnote_doc_1_trace_1", "runId": "run_1", "synced": true, "traceId": "trace_1" } +``` + +如果上游是 `/v1/runs/{run_id}/events`,BFF 只做字段规整;如果后续试验 `/v1/responses`,BFF 负责把 `response.*` 映射为上述 RuntimeEvent。前端不直接依赖上游原始事件格式。 + +--- + +## 7. 下一阶段 checklist + +### A. Runtime 状态注册表 + +- [x] 在 `mnote-web` 建立内存级 `sessionId -> active run state` registry。 +- [x] 记录 `sessionId`、`runId`、`profile`、`documentId`、`traceId`、`status`、`startedAt`、`lastEventAt`。 +- [x] registry 只保存运行态和最近事件摘要,不保存完整聊天历史。 +- [x] 服务重启后允许 registry 丢失,但页面必须能从 Hermes session detail 恢复已完成消息。 + +验收标准: + +- [x] 页面刷新时,若 run 仍在 registry 中,前端能看到 `running/tool_calling` 状态。 +- [x] 关闭页面再打开,不会把 mnote local storage 当作聊天真相。 + +### B. Resume + +- [x] 增加 `POST /api/hermes/client/sessions/{session_id}/resume` 或等价同源接口。 +- [x] 返回 Hermes session detail 中的 messages,并附带 registry 中当前运行态。 +- [x] 若 session 不在 registry 中,只返回 Hermes 已持久化状态。 + +验收标准: + +- [x] run 过程中刷新页面,UI 能恢复 run 状态或明确显示“正在同步 Hermes 状态”。 +- [x] run 完成后刷新页面,消息来自 Hermes session detail / export,不来自 mnote 自建聊天表。 + +验证证据: + +- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 9 个 Hermes client 单测,覆盖 registry 不保存 `messages`、tool event 摘要和 `/resume` 返回 runtime。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。13000 是本轮临时启动的新 `mnote-web`,用于避免打断当前 3000;验证后已关闭。 +- 当前 3000 运行的是本轮修改前的旧 `mnote-web` 二进制,`POST /api/hermes/client/sessions/{id}/resume` 返回 404;需要重启 3000 后再用同一 smoke 复验主入口。 + +### C. Queue + +- [x] 同一 session 已有 active run 时,后续输入进入后端 queue。 +- [x] queue item 只保存待发送 input、profile、context snapshot 摘要和 trace,不保存长期聊天真相。 +- [x] 当前 run 结束后自动触发下一条。 +- [x] 前端显示 queue length 和可取消项。 + +验收标准: + +- [x] 连续发送两条消息时,第二条显示 queued,而不是并发打到同一 Hermes session。 +- [x] 取消 queued item 后,不触发 Hermes run。 + +验证证据: + +- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 12 个 Hermes client 单测,新增覆盖 active run 时第二条入队、queue item 不返回完整 `messages/pageContext`、后端取消 queued item、终态后用 mock Hermes upstream 自动启动下一条 queued run。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js`:通过。验证连续发送两条消息时第二条走 `queued` 响应,UI 显示队列长度,并能从 Runtime 面板取消 queued item。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。确认 resume / tool / 完成态基础链路未被 queue 改动破坏。 +- 13000 是本轮临时启动的新 `mnote-web`,验证后已关闭;当前 3000 仍需重启后复验。 + +### D. Abort + +- [x] abort 由 BFF 统一执行,调用 Hermes `/v1/runs/{run_id}/stop` 或中断当前 upstream request。 +- [x] abort 前后发出 `abort.started`、`abort.completed`。 +- [x] abort 后清理 registry 状态,并处理队列后续项。 + +验收标准: + +- [x] 点击停止后,UI 不再继续追加该 run 的 assistant delta。 +- [x] 若 abort 后有队列项,后端按顺序继续处理或明确要求用户确认。 + +验证证据: + +- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 13 个 Hermes client 单测,新增覆盖 abort 响应返回 `abort.started/abort.completed`,并在 abort 后继续处理 queued run。 +- 临时 `mnote-web` 以 `MNOTE_WEB_HERMES_UPSTREAM_URL=http://127.0.0.1:3999` 启动后,执行 `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-runtime-controls-smoke.js`:通过。验证停止 run 后 UI 状态进入 `aborted`,并且后续 `message.delta` 不再追加到聊天区。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js` 与 `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。确认 queue / resume 基础链路未被 abort 改动破坏。 +- 13000 是本轮临时启动的新 `mnote-web`,验证后已关闭;当前 3000 仍需重启后复验。 + +### E. Event normalization + +- [x] mnote-web 将 Hermes 上游事件规整为 `mnote.hermes_runtime_event.v1`。 +- [x] 前端只消费标准事件,不直接分支处理多套 Hermes 原始事件。 +- [x] tool event 展示独立于 assistant 文本。 +- [x] 当前 UI 已能在聊天区输出 `工具` 消息并显示工具名,例如 `mnote.page.get`;下一阶段必须升级为按 `toolCallId` 合并的 tool event card。 +- [x] `tool.started` 创建或更新同一个工具卡,显示工具名、运行中状态、参数摘要。 +- [x] `tool.completed` 原地更新同一个工具卡,显示完成状态、结果摘要、audit/trace id。 +- [x] `tool.failed` 原地更新同一个工具卡,显示失败状态、错误码和可复制详情。 +- [x] Runtime 页的最近 tool call 摘要继续保留,但不能替代聊天区内联工具卡。 + +验收标准: + +- [x] `message.delta`、`tool.started`、`tool.completed`、`run.completed`、`run.failed` 均有 smoke 覆盖。 +- [x] `tool.started -> tool.completed` 不产生两条互不关联的纯文本工具消息;同一 `toolCallId` 在 UI 中表现为同一张工具卡状态变化。 +- [x] 工具卡至少展示 `toolName`、`toolCallId`、状态、参数摘要、结果摘要、`traceId/auditId`。 +- [x] tool result 不混入正文编辑器,不写入 page body,除非 Hermes 明确调用 `mnote.page.save` 等写工具。 + +验证证据: + +- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-smoke.js`:通过。smoke 发送同一 `toolCallId=call_smoke_page_get` 的 `tool.started -> tool.completed`,断言聊天区只有 1 张 completed 工具卡;同时发送 `toolCallId=call_smoke_page_save` 的 `tool.started -> tool.failed`,断言 failed 工具卡显示 `permission_denied`。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-runtime-controls-smoke.js` 与 `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-queue-smoke.js`:通过。确认工具卡改动未破坏 abort / queue。 +- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 14 个 Hermes client 单测,新增覆盖 `assistant_message`、`tool.failed` 等上游事件被规整为 `mnote.hermes_runtime_event.v1`。 +- mnote-web `/client/events/{run_id}` 输出 `event: ` 与 `schemaVersion: "mnote.hermes_runtime_event.v1"`;前端消费路径只处理 `message.delta`、`run.completed`、`run.failed`、`run.aborted` 与 `tool.*` 标准事件名。 + +### F. Gateway manager + +- [x] BFF 能检查当前 Hermes gateway health。 +- [x] BFF 能识别 profile 对应 gateway / model 状态。 +- [x] 缺 model/default 或 API key 时,错误提示指向 Hermes 设置,而不是只显示 502。 + +验收标准: + +- [x] `hermes_client_upstream_error` 场景能显示具体修复建议,例如“当前 profile 缺 model.default”。 +- [x] profile/skill/settings API 不再错误代理到 Hermes gateway 非 `/v1/*` 路径。 + +验证证据: + +- `mnote-web` 新增 `GET /api/hermes/client/gateway/health?profile=`,只做只读探测,不启动/停止 gateway;返回 `gateway.configured/upstream/status/httpStatus`、`profile.modelDefault/provider/apiKeyConfigured` 与 `suggestions`。 +- `profile_detail_payload()` 与 fallback profiles 会从 Hermes profile `config.yaml` 读取 `model.default`、`model.provider`、provider `api_key/key_env` 摘要,但不返回 API key 原文。 +- `upstream_error()` 对 `hermes_client_upstream_error` 增加 Hermes 设置修复建议;当上游错误正文包含 `model.default`、`API key`、`profile/provider` 等线索时,错误 message 会指向 Hermes 设置。 +- Runtime 面板增加 gateway/profile 状态区;页面请求失败时前端显示后端 `message`,不再只显示错误 code。 +- `cargo test -p mnote-web hermes_client_ -- --nocapture`:通过 17 个 Hermes client 单测,新增覆盖 gateway 未配置、profile 缺 model/API key、`hermes_client_upstream_error` 错误建议。 +- 临时 `mnote-web` 以 `MNOTE_WEB_HERMES_UPSTREAM_URL=http://127.0.0.1:3999` 启动到 `127.0.0.1:13000` 后,`task-hermes-page-ai-smoke.js`、`task-hermes-page-ai-runtime-controls-smoke.js`、`task-hermes-page-ai-queue-smoke.js` 均通过;验证后已关闭 13000。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:13000 node scripts/task-hermes-page-ai-agent-skill-smoke.js`:通过。确认新增 gateway health 请求不影响 agent/profile 选择、SOUL.md 保存、skill toggle 与 run profile 透传。 + +### G. `/v1/responses` 评估,不默认切换 + +- [x] 增加只读 spike 文档或测试,验证当前 Hermes gateway 的 `/v1/responses stream=true` 事件格式。 +- [x] 明确 `/v1/responses` 是否能在不迁移会话真相的前提下复用 Hermes session。 +- [x] 若不能保持 Hermes session 真相,则不得进入默认页面 AI 主链。 + +验收标准: + +- [x] 有明确结论:继续 `/v1/runs`,或另开设计稿说明为什么改变会话真相。 +- [x] 任何 `/v1/responses` 实验都不得把 mnote Convex / page aggregate 变成聊天消息真相。 + +只读 spike 结论: + +- 参考 `hermes-web-ui-0.5.18/packages/server/src/services/hermes/chat-run-socket.ts`,其 `/v1/responses` 主链在发送前从后端 DB 组装 `conversation_history`,随后设置 `body.stream = true` 与 `body.store = false`,再请求 `${upstream}/v1/responses`。 +- 同一文件的 `applyResponseStreamEvent()` 表明 responses 事件格式主要是 `response.created`、`response.output_text.delta`、`response.output_item.added`、`response.output_item.done`、`response.completed`、`response.failed`,由上层 BFF 映射为 `run.started`、`message.delta`、`tool.started`、`tool.completed`、`run.completed`、`run.failed`。 +- `response.output_item.done` 中 `function_call` 会被映射为 `tool.started`;`function_call_output` 会被映射为 `tool.completed`。这对 mnote 的 tool card 状态机有参考价值,但不改变主链。 +- 本机只读探测 `http://127.0.0.1:3999/health`、`/v1/models`、`OPTIONS /v1/responses` 时,当前没有 gateway 监听,无法取得 live `/v1/responses stream=true` 事件样本;因此本轮不把 live event capture 写成验收依据。 +- 基于参考实现,`/v1/responses store=false + conversation_history` 的顺滑体验依赖上层 BFF/SQLite 自建 chat session 真相。除非后续确认 Hermes gateway 支持把 `/v1/responses` 直接绑定到 Hermes session storage,否则 mnote 不应把它切成默认主链。 + +当前决策: + +> **继续 `/v1/runs` 作为页面 AI 默认主链;`/v1/responses` 只作为事件标准化与 tool card 的参考实现。若未来要切换,必须另开设计稿证明 Hermes session 仍是 AI 会话真相,或明确批准迁移会话真相。** + +--- + +## 8. 非目标 + +- 不在本阶段引入 ACP 主链。 +- 不在本阶段把页面 AI 主链切到 `/v1/responses store=false`。 +- 不在 mnote 建 Hermes Web UI 那样的 SQLite chat DB。 +- 不把 Convex 作为 AI 会话真相。 +- 不把页面 AI 抽屉扩成完整 Hermes 管理台。 +- 不让 mnote plugin 直接写 Convex;写入仍回到 Rust runtime / kernel。 + +--- + +## 9. 最终验收口径 + +本稿完成时,应能同时成立: + +- [x] 页面 AI 仍是 Hermes 页面内客户端。 +- [x] Hermes 仍是 AI session/message/tool event/usage/model/profile 真相。 +- [x] mnote-web 有 Runtime BFF 能力,支持 run resume、queue、abort 和事件标准化。 +- [x] 页面刷新或断线不导致 active run UI 状态丢失;完成后的消息从 Hermes 恢复。 +- [x] mnote 不保存完整聊天历史,只保存 mnote 业务写入结果和 audit。 +- [x] `/v1/responses` 是否进入主链已有单独评估结论,且没有绕过 Hermes session 真相。 + +一句话: + +> **补齐 Web Chat Runtime 的工程能力,但不改变“AI 会话归 Hermes,mnote 事实归 Rust kernel”的长期边界。** diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index 4252815f..095b6c01 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -8,14 +8,59 @@ use axum::Json; use futures_util::TryStreamExt; use serde::Deserialize; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; +use std::path::{Path as FsPath, PathBuf}; use std::process::Command; +use std::sync::{LazyLock, Mutex}; use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; +use tracing::warn; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner"; +static HERMES_RUNTIME_REGISTRY: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static HERMES_RUN_QUEUE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone)] +struct HermesRuntimeState { + session_id: String, + run_id: String, + profile: String, + document_id: String, + trace_id: String, + status: String, + started_at: u128, + last_event_at: u128, + last_event: Option, + last_tool_name: Option, + last_tool_call_id: Option, + last_audit_id: Option, +} + +#[derive(Debug, Clone)] +struct HermesRunRegistration { + session_id: String, + profile: String, + document_id: String, + trace_id: String, +} + +#[derive(Debug, Clone)] +struct HermesQueuedRun { + queue_id: String, + session_id: String, + profile: String, + document_id: String, + trace_id: String, + input: String, + context_summary: Value, + queued_at: u128, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { @@ -88,17 +133,80 @@ 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 + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(list_profiles_payload()), + )) +} + +pub async fn gateway_health( + Extension(context): Extension, + Query(query): Query>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let profile = query + .get("profile") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(active_profile_name) + .unwrap_or_else(|| "default".into()); + let upstream = configured_upstream(); + let profile_status = profile_gateway_status(&profile); + let mut suggestions = profile_status + .get("suggestions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if upstream.is_none() { + suggestions.push(Value::String( + "未配置 MNOTE_WEB_HERMES_UPSTREAM_URL;请先启动 Hermes gateway,并让 mnote-web 指向该 gateway。" + .into(), + )); + } + + let mut gateway = json!({ + "configured": upstream.is_some(), + "upstream": upstream, + "ok": false, + "status": if upstream.is_some() { "checking" } else { "unconfigured" } + }); + if let Some(upstream_url) = gateway["upstream"].as_str().map(ToOwned::to_owned) { + let probe = probe_gateway_health(&upstream_url).await; + gateway["ok"] = Value::Bool(probe.ok); + gateway["status"] = Value::String(probe.status); + gateway["httpStatus"] = probe.http_status.map(Value::from).unwrap_or(Value::Null); + gateway["path"] = probe.path.map(Value::from).unwrap_or(Value::Null); + gateway["message"] = probe.message.map(Value::from).unwrap_or(Value::Null); + if !probe.ok { + suggestions.extend( + hermes_settings_suggestions( + gateway["httpStatus"].as_u64(), + gateway["message"].as_str(), + ) + .into_iter() + .map(Value::String), + ); + } + } + let ok = gateway["ok"].as_bool().unwrap_or(false) + && profile_status["modelConfigured"].as_bool().unwrap_or(false) + && profile_status["apiKeyConfigured"] + .as_bool() + .unwrap_or(false); + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": ok, + "traceId": context.trace.trace_id, + "profile": profile_status, + "gateway": gateway, + "suggestions": unique_string_values(suggestions) + })), + )) } pub async fn get_profile( @@ -106,17 +214,14 @@ pub async fn get_profile( 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 + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "profile": profile_detail_payload(&profile_name) + })), + )) } pub async fn switch_active_profile( @@ -124,34 +229,51 @@ pub async fn switch_active_profile( 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 + let name = payload + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 profile name") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + switch_active_profile_local(name).map_err(|error| { + WebError::bad_gateway_code( + "hermes_client_profile_switch_failed", + format!("切换 Hermes profile 失败: {error}"), + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "profile": profile_detail_payload(name) + })), + )) } pub async fn get_profile_memory( Extension(context): Extension, + Query(query): Query>, ) -> 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 + let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); + let profile = query + .get("profile") + .map(String::as_str) + .unwrap_or(fallback_profile.as_str()); + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(profile_memory_payload(profile)), + )) } pub async fn save_profile_memory( @@ -159,17 +281,43 @@ pub async fn save_profile_memory( 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 + let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); + let profile = payload + .get("profile") + .and_then(Value::as_str) + .unwrap_or(fallback_profile.as_str()); + let section = payload + .get("section") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| matches!(*value, "memory" | "user" | "soul")) + .ok_or_else(|| { + WebError::bad_request_code( + "hermes_client_bad_request", + "section 必须是 memory、user 或 soul", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + let content = payload + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + save_profile_memory_local(profile, section, content).map_err(|error| { + WebError::bad_gateway_code( + "hermes_client_memory_save_failed", + format!("保存 Hermes profile memory 失败: {error}"), + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({"ok": true})), + )) } pub async fn list_skills( @@ -177,20 +325,16 @@ pub async fn list_skills( 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 + let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); + let profile = query + .get("profile") + .map(String::as_str) + .unwrap_or(fallback_profile.as_str()); + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(skills_payload(profile)), + )) } pub async fn toggle_skill( @@ -198,17 +342,45 @@ pub async fn toggle_skill( 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 + let name = payload + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 skill name") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + let enabled = payload + .get("enabled") + .and_then(Value::as_bool) + .ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 enabled") + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + let fallback_profile = active_profile_name().unwrap_or_else(|| "default".into()); + let profile = payload + .get("profile") + .and_then(Value::as_str) + .unwrap_or(fallback_profile.as_str()); + set_skill_enabled(profile, name, enabled).map_err(|error| { + WebError::bad_gateway_code( + "hermes_client_skill_toggle_failed", + format!("更新 Hermes skill 设置失败: {error}"), + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({"ok": true})), + )) } pub async fn get_session( @@ -216,6 +388,7 @@ pub async fn get_session( Path(session_id): Path, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; + let runtime = runtime_state_for_session(&session_id); if let Some(session) = load_session_from_hermes_cli(&session_id).await { return Ok(( StatusCode::OK, @@ -224,21 +397,32 @@ pub async fn get_session( "ok": true, "traceId": context.trace.trace_id, "sessionId": session_id, - "session": session + "session": session, + "runtime": runtime })), )); } - let Some(upstream) = configured_upstream() else { - return hermes_unconfigured(&context); - }; - proxy_json( - &context, - reqwest::Method::GET, - &upstream, - &format!("/api/hermes/sessions/{}", url_escape(&session_id)), - None, - ) - .await + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "traceId": context.trace.trace_id, + "sessionId": session_id, + "session": { + "sessionId": session_id, + "messages": [] + }, + "runtime": runtime + })), + )) +} + +pub async fn resume_session( + Extension(context): Extension, + Path(session_id): Path, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + get_session(Extension(context), Path(session_id)).await } async fn load_session_from_hermes_cli(session_id: &str) -> Option { @@ -270,18 +454,49 @@ pub async fn create_run( Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { ensure_authenticated(&context)?; + let registration = run_registration_from_payload(&context, &payload); + if session_has_active_run(®istration.session_id) { + let queued = enqueue_run(&context, ®istration, &payload)?; + return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued))); + } let Some(upstream) = configured_upstream() else { return hermes_unconfigured(&context); }; let upstream_body = build_run_upstream_body(&context, payload)?; - proxy_json( + let result = proxy_json( &context, reqwest::Method::POST, &upstream, "/v1/runs", Some(upstream_body), ) - .await + .await?; + if let Some(runtime) = register_runtime_from_create_run_response(®istration, &result.2 .0) { + let mut payload = result.2 .0; + payload["runtime"] = runtime; + Ok((result.0, result.1, Json(payload))) + } else { + Ok(result) + } +} + +pub async fn cancel_queued_run( + Extension(context): Extension, + Path((session_id, queue_id)): Path<(String, String)>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let cancelled = remove_queued_run(&session_id, &queue_id); + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "sessionId": session_id, + "queueId": queue_id, + "cancelled": cancelled, + "queueLength": queue_len_for_session(&session_id) + })), + )) } pub async fn stream_events( @@ -321,12 +536,29 @@ pub async fn stream_events( return Err(upstream_error(&context, status, text)); } - let stream = upstream_response.bytes_stream().map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("Hermes events stream 读取失败: {error}"), - ) - }); + let run_id_for_stream = run_id.clone(); + let context_for_queue = context.clone(); + update_runtime_by_run_id(&run_id, "running", Some("events.connected"), None); + let stream = upstream_response + .bytes_stream() + .map_ok(move |chunk| { + let text = String::from_utf8_lossy(&chunk); + let (normalized, terminal_sessions) = + normalize_sse_chunk(&run_id_for_stream, text.as_ref()); + for session_id in terminal_sessions { + let context = context_for_queue.clone(); + tokio::spawn(async move { + start_next_queued_run(context, session_id).await; + }); + } + axum::body::Bytes::from(normalized) + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Hermes events stream 读取失败: {error}"), + ) + }); let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") @@ -347,14 +579,34 @@ pub async fn abort_run( let Some(upstream) = configured_upstream() else { return hermes_unconfigured(&context); }; - proxy_json( + let queued_session_id = session_id_for_run(&run_id); + update_runtime_by_run_id(&run_id, "aborting", Some("abort.started"), None); + let result = proxy_json( &context, reqwest::Method::POST, &upstream, &format!("/v1/runs/{}/stop", url_escape(&run_id)), Some(payload), ) - .await + .await; + match result { + Ok((status, headers, Json(mut payload))) => { + update_runtime_by_run_id(&run_id, "aborted", Some("abort.completed"), None); + payload["runtime"] = runtime_state_for_run(&run_id).unwrap_or(Value::Null); + payload["events"] = json!([ + {"event": "abort.started", "runId": run_id}, + {"event": "abort.completed", "runId": run_id} + ]); + if let Some(session_id) = queued_session_id { + start_next_queued_run(context.clone(), session_id).await; + } + Ok((status, headers, Json(payload))) + } + Err(error) => { + update_runtime_by_run_id(&run_id, "failed", Some("abort.failed"), None); + Err(error) + } + } } pub async fn list_models( @@ -438,6 +690,546 @@ pub async fn list_tools( )) } +fn hermes_bin() -> String { + std::env::var("MNOTE_WEB_HERMES_BIN") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "hermes".into()) +} + +fn hermes_home() -> PathBuf { + std::env::var("HERMES_HOME") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var("HOME") + .ok() + .map(|home| PathBuf::from(home).join(".hermes")) + }) + .unwrap_or_else(|| PathBuf::from(".hermes")) +} + +fn active_profile_name() -> Option { + fs::read_to_string(hermes_home().join("active_profile")) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn profile_home(profile: &str) -> PathBuf { + let home = hermes_home(); + let profile = profile.trim(); + if profile.is_empty() || profile == "default" { + return home; + } + let candidate = home.join("profiles").join(profile); + if candidate.exists() { + candidate + } else { + home + } +} + +fn profile_config_path(profile: &str) -> PathBuf { + profile_home(profile).join("config.yaml") +} + +fn parse_profile_list(stdout: &str) -> Vec { + let active = active_profile_name().unwrap_or_else(|| "default".into()); + stdout + .replace("\r\n", "\n") + .replace('\r', "\n") + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("Profile") || trimmed.starts_with('─') { + return None; + } + let is_active = trimmed.starts_with('◆'); + let without_marker = trimmed.trim_start_matches('◆').trim(); + let parts = without_marker + .split_whitespace() + .map(str::to_string) + .collect::>(); + if parts.is_empty() { + return None; + } + let name = parts[0].clone(); + let model = parts + .get(1) + .filter(|value| value.as_str() != "—") + .cloned() + .unwrap_or_default(); + let gateway = parts + .get(2) + .filter(|value| value.as_str() != "—") + .cloned() + .unwrap_or_default(); + let alias = parts + .get(3) + .filter(|value| value.as_str() != "—") + .cloned() + .unwrap_or_default(); + Some(json!({ + "name": name, + "active": name == active || (active == "default" && is_active), + "model": model, + "gateway": gateway, + "alias": alias + })) + }) + .collect() +} + +fn fallback_profiles() -> Vec { + let active = active_profile_name().unwrap_or_else(|| "default".into()); + let default_status = profile_gateway_status("default"); + let mut profiles = vec![json!({ + "name": "default", + "active": active == "default", + "model": default_status["modelDefault"].as_str().unwrap_or_default(), + "gateway": default_status["gateway"].as_str().unwrap_or_default(), + "provider": default_status["provider"].as_str().unwrap_or_default(), + "alias": "" + })]; + let profiles_dir = hermes_home().join("profiles"); + if let Ok(entries) = fs::read_dir(profiles_dir) { + let mut names = entries + .filter_map(Result::ok) + .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect::>(); + names.sort(); + profiles.extend(names.into_iter().map(|name| { + let status = profile_gateway_status(&name); + json!({ + "name": name, + "active": name == active, + "model": status["modelDefault"].as_str().unwrap_or_default(), + "gateway": status["gateway"].as_str().unwrap_or_default(), + "provider": status["provider"].as_str().unwrap_or_default(), + "alias": "" + }) + })); + } + profiles +} + +fn list_profiles_payload() -> Value { + let profiles = Command::new(hermes_bin()) + .args(["profile", "list"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|stdout| parse_profile_list(&stdout)) + .filter(|profiles| !profiles.is_empty()) + .unwrap_or_else(fallback_profiles); + json!({ + "ok": true, + "profiles": profiles + }) +} + +fn profile_detail_payload(profile: &str) -> Value { + let dir = profile_home(profile); + let gateway_status = profile_gateway_status(profile); + json!({ + "name": if profile.trim().is_empty() { "default" } else { profile.trim() }, + "path": dir.to_string_lossy(), + "model": gateway_status["modelDefault"].as_str().unwrap_or_default(), + "provider": gateway_status["provider"].as_str().unwrap_or_default(), + "gateway": gateway_status["gateway"].as_str().unwrap_or_default(), + "modelConfigured": gateway_status["modelConfigured"].as_bool().unwrap_or(false), + "apiKeyConfigured": gateway_status["apiKeyConfigured"].as_bool().unwrap_or(false), + "suggestions": gateway_status["suggestions"].clone(), + "skills": skills_payload(profile)["categories"].as_array().map(Vec::len).unwrap_or_default(), + "hasEnv": dir.join(".env").exists(), + "hasSoulMd": dir.join("SOUL.md").exists() + }) +} + +fn switch_active_profile_local(profile: &str) -> std::io::Result<()> { + let command_result = Command::new(hermes_bin()) + .args(["profile", "use", profile]) + .output(); + if command_result + .as_ref() + .map(|output| output.status.success()) + .unwrap_or(false) + { + return Ok(()); + } + let home = hermes_home(); + fs::create_dir_all(&home)?; + if profile != "default" { + fs::create_dir_all(home.join("profiles").join(profile))?; + } + fs::write(home.join("active_profile"), profile.as_bytes()) +} + +fn file_text(path: &FsPath) -> String { + fs::read_to_string(path).unwrap_or_default() +} + +fn file_mtime_ms(path: &FsPath) -> Option { + let modified = fs::metadata(path).ok()?.modified().ok()?; + let duration = modified.duration_since(std::time::UNIX_EPOCH).ok()?; + Some(duration.as_millis() as i128) +} + +fn profile_memory_payload(profile: &str) -> Value { + let dir = profile_home(profile); + let memory_path = dir.join("memories").join("MEMORY.md"); + let user_path = dir.join("memories").join("USER.md"); + let soul_path = dir.join("SOUL.md"); + json!({ + "ok": true, + "memory": file_text(&memory_path), + "user": file_text(&user_path), + "soul": file_text(&soul_path), + "memory_mtime": file_mtime_ms(&memory_path), + "user_mtime": file_mtime_ms(&user_path), + "soul_mtime": file_mtime_ms(&soul_path) + }) +} + +fn save_profile_memory_local(profile: &str, section: &str, content: &str) -> std::io::Result<()> { + let dir = profile_home(profile); + let path = match section { + "soul" => dir.join("SOUL.md"), + "user" => dir.join("memories").join("USER.md"), + _ => dir.join("memories").join("MEMORY.md"), + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content) +} + +fn disabled_skills(profile: &str) -> Vec { + let content = fs::read_to_string(profile_config_path(profile)).unwrap_or_default(); + let mut disabled = Vec::new(); + let mut in_skills = false; + let mut in_disabled = false; + for line in content.lines() { + let trimmed = line.trim(); + if !line.starts_with(' ') && !trimmed.is_empty() { + in_skills = trimmed == "skills:"; + in_disabled = false; + continue; + } + if in_skills && trimmed == "disabled:" { + in_disabled = true; + continue; + } + if in_disabled { + if let Some(value) = trimmed.strip_prefix("- ") { + disabled.push( + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(), + ); + } else if !trimmed.is_empty() && !trimmed.starts_with('#') { + in_disabled = false; + } + } + } + disabled +} + +fn extract_skill_description(markdown: &str) -> String { + markdown + .lines() + .find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + Some( + trimmed + .trim_start_matches("Description:") + .trim() + .to_string(), + ) + }) + .unwrap_or_default() +} + +struct SkillCatalogMeta { + bundled: HashSet, + hub_installed: HashSet, + usage: HashMap, +} + +impl SkillCatalogMeta { + fn read(skills_dir: &FsPath) -> Self { + Self { + bundled: read_bundled_skill_names(&skills_dir.join(".bundled_manifest")), + hub_installed: read_hub_installed_skill_names( + &skills_dir.join(".hub").join("lock.json"), + ), + usage: read_skill_usage(&skills_dir.join(".usage.json")), + } + } + + fn usage_for(&self, name: &str) -> Option<&Value> { + self.usage.get(name) + } +} + +fn read_bundled_skill_names(path: &FsPath) -> HashSet { + fs::read_to_string(path) + .ok() + .map(|content| { + content + .lines() + .filter_map(|line| { + line.split_once(':') + .map(|(name, _)| name.trim().to_string()) + }) + .filter(|name| !name.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +fn read_hub_installed_skill_names(path: &FsPath) -> HashSet { + let Ok(content) = fs::read_to_string(path) else { + return HashSet::new(); + }; + let Ok(payload) = serde_json::from_str::(&content) else { + return HashSet::new(); + }; + payload + .get("installed") + .and_then(Value::as_object) + .map(|installed| { + installed + .iter() + .flat_map(|(name, entry)| { + let mut names = vec![name.to_string()]; + if let Some(path) = entry.get("install_path").and_then(Value::as_str) { + if let Some(last) = path.rsplit('/').find(|part| !part.trim().is_empty()) { + names.push(last.to_string()); + } + } + names + }) + .collect() + }) + .unwrap_or_default() +} + +fn read_skill_usage(path: &FsPath) -> HashMap { + let Ok(content) = fs::read_to_string(path) else { + return HashMap::new(); + }; + serde_json::from_str::(&content) + .ok() + .and_then(|payload| payload.as_object().cloned()) + .map(|items| items.into_iter().collect()) + .unwrap_or_default() +} + +fn skill_source(name: &str, meta: &SkillCatalogMeta) -> &'static str { + if meta.bundled.contains(name) { + "builtin" + } else if meta.hub_installed.contains(name) { + "hub" + } else { + "local" + } +} + +fn skill_is_agent_generated(usage: Option<&Value>) -> bool { + usage + .and_then(Value::as_object) + .map(|record| { + record + .get("created_by") + .and_then(Value::as_str) + .map(|value| value == "agent") + .unwrap_or(false) + || record + .get("agent_created") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .unwrap_or(false) +} + +fn skill_origin(source: &str, usage: Option<&Value>) -> &'static str { + if skill_is_agent_generated(usage) { + "generated" + } else if source == "builtin" { + "builtin" + } else if source == "hub" { + "installed" + } else { + "copied" + } +} + +fn skill_entry( + name: String, + category: String, + path: PathBuf, + disabled: &[String], + meta: &SkillCatalogMeta, +) -> Option { + let content = fs::read_to_string(path).ok()?; + let source = skill_source(&name, meta); + let usage = meta.usage_for(&name); + Some(json!({ + "name": name, + "description": extract_skill_description(&content), + "enabled": !disabled.iter().any(|item| item == &name), + "source": source, + "origin": skill_origin(source, usage), + "createdBy": usage.and_then(|record| record.get("created_by")).and_then(Value::as_str), + "createdAt": usage.and_then(|record| record.get("created_at")).and_then(Value::as_str), + "patchCount": usage.and_then(|record| record.get("patch_count")).and_then(Value::as_i64), + "category": category + })) +} + +fn skills_payload(profile: &str) -> Value { + let skills_dir = hermes_home().join("skills"); + let disabled = disabled_skills(profile); + let meta = SkillCatalogMeta::read(&skills_dir); + let mut categories = Vec::new(); + let Ok(entries) = fs::read_dir(&skills_dir) else { + return json!({"ok": true, "categories": [], "archived": []}); + }; + let mut dirs = entries + .filter_map(Result::ok) + .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) + .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) + .collect::>(); + dirs.sort_by_key(|entry| entry.file_name()); + for entry in dirs { + let name = entry.file_name().to_string_lossy().to_string(); + let dir = entry.path(); + let direct_skill = dir.join("SKILL.md"); + if direct_skill.exists() { + if let Some(skill) = skill_entry(name, "misc".into(), direct_skill, &disabled, &meta) { + categories.push(json!({ + "name": "misc", + "description": "misc", + "skills": [skill] + })); + } + continue; + } + let mut skills = fs::read_dir(&dir) + .ok() + .into_iter() + .flat_map(|entries| entries.filter_map(Result::ok)) + .filter(|child| child.file_type().map(|kind| kind.is_dir()).unwrap_or(false)) + .filter_map(|child| { + let skill_name = child.file_name().to_string_lossy().to_string(); + skill_entry( + skill_name, + name.clone(), + child.path().join("SKILL.md"), + &disabled, + &meta, + ) + }) + .collect::>(); + if !skills.is_empty() { + skills.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string()); + categories.push(json!({ + "name": name, + "description": "", + "skills": skills + })); + } + } + json!({ + "ok": true, + "categories": merge_misc_categories(categories), + "archived": [] + }) +} + +fn merge_misc_categories(categories: Vec) -> Vec { + let mut merged = Vec::new(); + let mut misc = Vec::new(); + for category in categories { + if category["name"].as_str() == Some("misc") { + if let Some(skills) = category["skills"].as_array() { + misc.extend(skills.iter().cloned()); + } + } else { + merged.push(category); + } + } + if !misc.is_empty() { + misc.sort_by_key(|skill| skill["name"].as_str().unwrap_or_default().to_string()); + merged.insert( + 0, + json!({ + "name": "misc", + "description": "misc", + "skills": misc + }), + ); + } + merged +} + +fn set_skill_enabled(profile: &str, name: &str, enabled: bool) -> std::io::Result<()> { + let path = profile_config_path(profile); + let mut disabled = disabled_skills(profile); + if enabled { + disabled.retain(|item| item != name); + } else if !disabled.iter().any(|item| item == name) { + disabled.push(name.to_string()); + } + disabled.sort(); + let existing = fs::read_to_string(&path).unwrap_or_default(); + let mut kept = Vec::new(); + let mut in_skills = false; + let mut in_disabled = false; + for line in existing.lines() { + let trimmed = line.trim(); + if !line.starts_with(' ') && !trimmed.is_empty() { + in_skills = trimmed == "skills:"; + in_disabled = false; + } + if in_skills && trimmed == "disabled:" { + in_disabled = true; + continue; + } + if in_disabled { + if trimmed.starts_with("- ") || trimmed.is_empty() { + continue; + } + in_disabled = false; + } + kept.push(line.to_string()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut output = kept.join("\n"); + if !output.ends_with('\n') && !output.is_empty() { + output.push('\n'); + } + output.push_str("skills:\n disabled:\n"); + for skill in disabled { + output.push_str(" - "); + output.push_str(&skill); + output.push('\n'); + } + fs::write(path, output) +} + fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> { let has_actor = context.auth.actor_id.trim() != "anonymous"; if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() { @@ -478,6 +1270,82 @@ fn configured_api_key() -> Option { .filter(|value| !value.is_empty()) } +#[derive(Debug)] +struct GatewayProbe { + ok: bool, + status: String, + http_status: Option, + path: Option, + message: Option, +} + +async fn probe_gateway_health(upstream: &str) -> GatewayProbe { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build() + { + Ok(client) => client, + Err(error) => { + return GatewayProbe { + ok: false, + status: "client_error".into(), + http_status: None, + path: None, + message: Some(format!("Hermes gateway health client 构造失败: {error}")), + }; + } + }; + for path in ["/health", "/v1/models"] { + let Ok(url) = upstream_url(upstream, path) else { + continue; + }; + let mut request = client.get(url); + if let Some(api_key) = configured_api_key() { + request = request.bearer_auth(api_key); + } + match request.send().await { + Ok(response) if response.status().is_success() => { + return GatewayProbe { + ok: true, + status: "ok".into(), + http_status: Some(response.status().as_u16() as u64), + path: Some(path.into()), + message: None, + }; + } + Ok(response) => { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if path == "/v1/models" || status != reqwest::StatusCode::NOT_FOUND { + return GatewayProbe { + ok: false, + status: "upstream_error".into(), + http_status: Some(status.as_u16() as u64), + path: Some(path.into()), + message: Some(text.chars().take(600).collect()), + }; + } + } + Err(error) => { + return GatewayProbe { + ok: false, + status: "unreachable".into(), + http_status: None, + path: Some(path.into()), + message: Some(error.to_string()), + }; + } + } + } + GatewayProbe { + ok: false, + status: "unknown".into(), + http_status: None, + path: None, + message: Some("Hermes gateway health 探测未返回有效结果".into()), + } +} + fn env_or_dotenv(key: &str) -> Option { if let Ok(value) = std::env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); @@ -511,30 +1379,151 @@ fn env_or_dotenv(key: &str) -> Option { None } -fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result { - let message = payload - .get("message") - .and_then(Value::as_str) - .map(str::trim) +fn yaml_path_value(content: &str, path: &[&str]) -> Option { + let mut stack: Vec<(usize, String)> = Vec::new(); + for raw_line in content.lines() { + let line = raw_line.trim_end_matches('\r'); + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') || !line.contains(':') { + continue; + } + let indent = line.chars().take_while(|ch| ch.is_whitespace()).count(); + while stack + .last() + .map(|(level, _)| *level >= indent) + .unwrap_or(false) + { + stack.pop(); + } + let Some((key, value)) = trimmed.split_once(':') else { + continue; + }; + let key = key.trim().trim_matches('"').trim_matches('\'').to_string(); + let value = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + stack.push((indent, key)); + let keys = stack + .iter() + .map(|(_, key)| key.as_str()) + .collect::>(); + if keys == path && !value.is_empty() { + return Some(value); + } + } + None +} + +fn profile_gateway_status(profile: &str) -> Value { + let normalized_profile = if profile.trim().is_empty() { + "default" + } else { + profile.trim() + }; + let config_path = profile_config_path(normalized_profile); + let config = fs::read_to_string(&config_path).unwrap_or_default(); + let model_default = yaml_path_value(&config, &["model", "default"]).unwrap_or_default(); + let provider = yaml_path_value(&config, &["model", "provider"]).unwrap_or_default(); + let gateway = yaml_path_value(&config, &["model", "gateway"]) + .or_else(|| yaml_path_value(&config, &["model", "base_url"])) + .unwrap_or_default(); + let model_api_key = yaml_path_value(&config, &["model", "api_key"]); + let model_key_env = yaml_path_value(&config, &["model", "key_env"]); + let provider_api_key = if provider.is_empty() { + None + } else { + yaml_path_value(&config, &["providers", &provider, "api_key"]) + }; + let provider_key_env = if provider.is_empty() { + None + } else { + yaml_path_value(&config, &["providers", &provider, "key_env"]) + }; + let key_env_configured = model_key_env + .as_deref() + .or(provider_key_env.as_deref()) + .and_then(env_or_dotenv) + .is_some(); + let api_key_configured = model_api_key + .as_deref() + .or(provider_api_key.as_deref()) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + || key_env_configured; + let mut suggestions = Vec::new(); + if model_default.is_empty() { + suggestions + .push("当前 Hermes profile 缺少 model.default,请在 Hermes 设置中选择默认模型。"); + } + if provider.is_empty() { + suggestions + .push("当前 Hermes profile 缺少 model.provider,请在 Hermes 设置中选择 provider。"); + } + if !api_key_configured { + suggestions.push("当前 Hermes profile 未检测到 provider API key 或 key_env,请在 Hermes 设置中补齐 API key。"); + } + json!({ + "name": normalized_profile, + "path": profile_home(normalized_profile).to_string_lossy(), + "configPath": config_path.to_string_lossy(), + "configExists": config_path.exists(), + "modelDefault": model_default, + "provider": provider, + "gateway": gateway, + "modelConfigured": !model_default.is_empty(), + "apiKeyConfigured": api_key_configured, + "suggestions": suggestions + }) +} + +fn unique_string_values(values: Vec) -> Vec { + let mut seen = HashSet::new(); + values + .into_iter() + .filter_map(|value| value.as_str().map(str::trim).map(ToOwned::to_owned)) .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .or_else(|| { - payload - .get("messages") - .and_then(Value::as_array) - .and_then(|messages| messages.last()) - .and_then(|message| message.get("content")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }) - .ok_or_else(|| { - WebError::bad_request_code("hermes_client_bad_request", "缺少 message") - .with_context(context) - .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") - .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") - })?; + .filter(|value| seen.insert(value.clone())) + .map(Value::String) + .collect() +} + +fn hermes_settings_suggestions(status: Option, message: Option<&str>) -> Vec { + let lower = message.unwrap_or_default().to_lowercase(); + let mut suggestions = Vec::new(); + if lower.contains("model.default") + || (lower.contains("model") && (lower.contains("default") || lower.contains("missing"))) + { + suggestions.push( + "当前 Hermes profile 可能缺少 model.default,请在 Hermes 设置中选择默认模型。".into(), + ); + } + if lower.contains("api key") + || lower.contains("apikey") + || lower.contains("unauthorized") + || lower.contains("forbidden") + || matches!(status, Some(401 | 403)) + { + suggestions.push("当前 Hermes provider 可能缺少 API key 或 key_env 未生效,请在 Hermes 设置中补齐 API key。".into()); + } + if lower.contains("profile") || lower.contains("provider") { + suggestions + .push("请检查当前页面 AI 选择的 Hermes profile、provider 与 gateway 是否匹配。".into()); + } + if suggestions.is_empty() { + suggestions.push("请检查 Hermes gateway 是否正在运行,并确认当前 profile 的 model.default、provider 与 API key 已配置。".into()); + } + suggestions +} + +fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result { + let message = extract_run_message(&payload).ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 message") + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; let document_id = payload .get("documentId") .and_then(Value::as_str) @@ -593,6 +1582,343 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +fn runtime_state_to_json(state: &HermesRuntimeState) -> Value { + json!({ + "sessionId": state.session_id, + "runId": state.run_id, + "profile": state.profile, + "documentId": state.document_id, + "traceId": state.trace_id, + "status": state.status, + "startedAt": state.started_at, + "lastEventAt": state.last_event_at, + "lastEvent": state.last_event, + "lastToolName": state.last_tool_name, + "lastToolCallId": state.last_tool_call_id, + "lastAuditId": state.last_audit_id + }) +} + +fn runtime_state_for_session(session_id: &str) -> Value { + let registry = HERMES_RUNTIME_REGISTRY + .lock() + .expect("hermes runtime registry"); + registry + .get(session_id) + .map(runtime_state_to_json) + .unwrap_or(Value::Null) +} + +fn runtime_state_for_run(run_id: &str) -> Option { + let registry = HERMES_RUNTIME_REGISTRY + .lock() + .expect("hermes runtime registry"); + registry + .values() + .find(|state| state.run_id == run_id) + .map(runtime_state_to_json) +} + +fn run_registration_from_payload( + context: &RequestContext, + payload: &Value, +) -> HermesRunRegistration { + let document_id = payload + .get("documentId") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("current") + .to_string(); + let trace_id = payload + .get("traceId") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&context.trace.trace_id) + .to_string(); + let session_id = payload + .get("sessionId") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| stable_session_id(&document_id, &trace_id)); + let profile = payload + .get("profile") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default") + .to_string(); + HermesRunRegistration { + session_id, + profile, + document_id, + trace_id, + } +} + +fn register_runtime_from_create_run_response( + registration: &HermesRunRegistration, + payload: &Value, +) -> Option { + let upstream = payload.get("upstream").unwrap_or(payload); + let run_id = upstream + .get("run_id") + .or_else(|| upstream.get("runId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let now = now_ms(); + let state = HermesRuntimeState { + session_id: registration.session_id.clone(), + run_id, + profile: registration.profile.clone(), + document_id: registration.document_id.clone(), + trace_id: registration.trace_id.clone(), + status: "running".into(), + started_at: now, + last_event_at: now, + last_event: Some("run.started".into()), + last_tool_name: None, + last_tool_call_id: None, + last_audit_id: None, + }; + let json = runtime_state_to_json(&state); + HERMES_RUNTIME_REGISTRY + .lock() + .expect("hermes runtime registry") + .insert(registration.session_id.clone(), state); + Some(json) +} + +fn update_runtime_by_run_id( + run_id: &str, + status: &str, + event: Option<&str>, + tool: Option<(Option, Option, Option)>, +) { + let mut registry = HERMES_RUNTIME_REGISTRY + .lock() + .expect("hermes runtime registry"); + let Some(state) = registry.values_mut().find(|state| state.run_id == run_id) else { + return; + }; + state.status = status.to_string(); + state.last_event_at = now_ms(); + if let Some(event) = event { + state.last_event = Some(event.to_string()); + } + if let Some((tool_name, tool_call_id, audit_id)) = tool { + if let Some(tool_name) = tool_name { + state.last_tool_name = Some(tool_name); + } + if let Some(tool_call_id) = tool_call_id { + state.last_tool_call_id = Some(tool_call_id); + } + if let Some(audit_id) = audit_id { + state.last_audit_id = Some(audit_id); + } + } +} + +fn normalize_sse_chunk(run_id: &str, chunk: &str) -> (String, Vec) { + let events = sse_json_events(chunk); + let terminal_sessions = update_runtime_from_events(run_id, &events); + let mut output = String::new(); + for event in events { + let normalized = normalize_runtime_event(run_id, event); + let event_name = normalized + .get("event") + .and_then(Value::as_str) + .unwrap_or("runtime.event"); + output.push_str("event: "); + output.push_str(event_name); + output.push_str("\ndata: "); + output.push_str(&normalized.to_string()); + output.push_str("\n\n"); + } + if output.is_empty() { + output.push_str(chunk); + } + (output, terminal_sessions) +} + +fn update_runtime_from_events(run_id: &str, events: &[Value]) -> Vec { + let mut terminal_sessions = Vec::new(); + for event in events { + let event_name = canonical_event_name(event); + if event_name.is_empty() { + continue; + } + let next_status = match event_name.as_str() { + "tool.started" => "tool_calling", + "tool.completed" | "tool.failed" => "running", + "run.completed" => "completed", + "run.failed" => "failed", + "run.aborted" => "aborted", + _ => "running", + }; + let tool = if event_name.starts_with("tool.") { + Some(( + event + .get("name") + .or_else(|| event.get("tool")) + .or_else(|| event.get("toolName")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + event + .get("tool_call_id") + .or_else(|| event.get("toolCallId")) + .or_else(|| event.get("id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + event + .get("audit_id") + .or_else(|| event.get("auditId")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + )) + } else { + None + }; + update_runtime_by_run_id(run_id, next_status, Some(&event_name), tool); + if matches!(next_status, "completed" | "failed" | "aborted") { + if let Some(session_id) = session_id_for_run(run_id) { + terminal_sessions.push(session_id); + } + } + } + terminal_sessions +} + +fn canonical_event_name(event: &Value) -> String { + let raw = event + .get("event") + .and_then(Value::as_str) + .or_else(|| event.get("type").and_then(Value::as_str)) + .unwrap_or_default(); + match raw { + "assistant_message" | "response.output_text.delta" => "message.delta", + "response.completed" => "run.completed", + "response.failed" => "run.failed", + "run.cancelled" | "run.canceled" | "abort.completed" => "run.aborted", + value => value, + } + .to_string() +} + +fn normalize_runtime_event(run_id: &str, event: Value) -> Value { + let event_name = canonical_event_name(&event); + let session_id = event + .get("session_id") + .or_else(|| event.get("sessionId")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .or_else(|| session_id_for_run(run_id)); + let trace_id = event + .get("trace_id") + .or_else(|| event.get("traceId")) + .and_then(Value::as_str) + .unwrap_or_default(); + let mut normalized = json!({ + "schemaVersion": "mnote.hermes_runtime_event.v1", + "event": event_name, + "runId": event.get("run_id").or_else(|| event.get("runId")).and_then(Value::as_str).unwrap_or(run_id), + "sessionId": session_id, + "traceId": trace_id, + "upstream": event + }); + if event_name == "message.delta" { + normalized["delta"] = event + .get("delta") + .or_else(|| event.get("text")) + .or_else(|| event.get("output_text")) + .cloned() + .unwrap_or(Value::Null); + } + if event_name.starts_with("tool.") { + normalized["toolName"] = event + .get("name") + .or_else(|| event.get("tool")) + .or_else(|| event.get("toolName")) + .cloned() + .unwrap_or(Value::Null); + normalized["toolCallId"] = event + .get("tool_call_id") + .or_else(|| event.get("toolCallId")) + .or_else(|| event.get("id")) + .cloned() + .unwrap_or(Value::Null); + normalized["args"] = event + .get("arguments") + .or_else(|| event.get("args")) + .or_else(|| event.get("input")) + .cloned() + .unwrap_or(Value::Null); + normalized["summary"] = event + .get("summary") + .or_else(|| event.get("result")) + .or_else(|| event.get("output")) + .cloned() + .unwrap_or(Value::Null); + normalized["code"] = event.get("code").cloned().unwrap_or(Value::Null); + normalized["error"] = event.get("error").cloned().unwrap_or(Value::Null); + normalized["auditId"] = event + .get("audit_id") + .or_else(|| event.get("auditId")) + .cloned() + .unwrap_or(Value::Null); + } + if event_name == "run.completed" { + normalized["output"] = event.get("output").cloned().unwrap_or(Value::Null); + normalized["usage"] = event.get("usage").cloned().unwrap_or(Value::Null); + } + if event_name == "run.failed" { + normalized["code"] = event.get("code").cloned().unwrap_or(Value::Null); + normalized["message"] = event + .get("message") + .or_else(|| event.get("error")) + .cloned() + .unwrap_or(Value::Null); + } + normalized +} + +fn session_id_for_run(run_id: &str) -> Option { + let registry = HERMES_RUNTIME_REGISTRY + .lock() + .expect("hermes runtime registry"); + registry + .values() + .find(|state| state.run_id == run_id) + .map(|state| state.session_id.clone()) +} + +fn sse_json_events(chunk: &str) -> Vec { + chunk + .split("\n\n") + .filter_map(|block| { + let data = block + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .collect::>() + .join("\n"); + if data.is_empty() || data == "[DONE]" { + return None; + } + serde_json::from_str::(&data).ok() + }) + .collect() +} + async fn proxy_json( context: &RequestContext, method: reqwest::Method, @@ -642,6 +1968,189 @@ async fn proxy_json( )) } +fn session_has_active_run(session_id: &str) -> bool { + let registry = HERMES_RUNTIME_REGISTRY + .lock() + .expect("hermes runtime registry"); + registry + .get(session_id) + .map(|state| { + matches!( + state.status.as_str(), + "queued" | "running" | "tool_calling" | "aborting" + ) + }) + .unwrap_or(false) +} + +fn queue_len_for_session(session_id: &str) -> usize { + HERMES_RUN_QUEUE + .lock() + .expect("hermes run queue") + .get(session_id) + .map(VecDeque::len) + .unwrap_or_default() +} + +fn queued_run_to_json(queued: &HermesQueuedRun, queue_length: usize) -> Value { + json!({ + "ok": true, + "queued": true, + "queueId": queued.queue_id, + "sessionId": queued.session_id, + "profile": queued.profile, + "documentId": queued.document_id, + "traceId": queued.trace_id, + "status": "queued", + "queueLength": queue_length, + "queuedAt": queued.queued_at, + "contextSummary": queued.context_summary + }) +} + +fn enqueue_run( + context: &RequestContext, + registration: &HermesRunRegistration, + payload: &Value, +) -> Result { + let input = extract_run_message(payload).ok_or_else(|| { + WebError::bad_request_code("hermes_client_bad_request", "缺少 message") + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + })?; + let queued = HermesQueuedRun { + queue_id: format!( + "queue_{}_{}", + sanitize_id_part(®istration.trace_id), + now_ms() + ), + session_id: registration.session_id.clone(), + profile: registration.profile.clone(), + document_id: registration.document_id.clone(), + trace_id: registration.trace_id.clone(), + input, + context_summary: queue_context_summary(payload), + queued_at: now_ms(), + }; + let queue_length = { + let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue"); + let entries = queue.entry(registration.session_id.clone()).or_default(); + entries.push_back(queued.clone()); + entries.len() + }; + Ok(queued_run_to_json(&queued, queue_length)) +} + +async fn start_next_queued_run(context: RequestContext, session_id: String) { + if session_has_active_run(&session_id) { + return; + } + let Some(queued) = pop_next_queued_run(&session_id) else { + return; + }; + let Some(upstream) = configured_upstream() else { + warn!(session_id = %session_id, queue_id = %queued.queue_id, "Hermes queue 无 upstream,无法自动启动下一条 run"); + return; + }; + let payload = queued_run_payload(&queued); + let upstream_body = match build_run_upstream_body(&context, payload) { + Ok(body) => body, + Err(error) => { + warn!(session_id = %session_id, queue_id = %queued.queue_id, error = ?error, "Hermes queue 构造 run body 失败"); + return; + } + }; + let registration = HermesRunRegistration { + session_id: queued.session_id.clone(), + profile: queued.profile.clone(), + document_id: queued.document_id.clone(), + trace_id: queued.trace_id.clone(), + }; + match proxy_json( + &context, + reqwest::Method::POST, + &upstream, + "/v1/runs", + Some(upstream_body), + ) + .await + { + Ok((_, _, Json(payload))) => { + let _ = register_runtime_from_create_run_response(®istration, &payload); + } + Err(error) => { + warn!(session_id = %session_id, queue_id = %queued.queue_id, error = ?error, "Hermes queue 自动启动下一条 run 失败"); + } + } +} + +fn queued_run_payload(queued: &HermesQueuedRun) -> Value { + json!({ + "documentId": queued.document_id, + "sessionId": queued.session_id, + "profile": queued.profile, + "message": queued.input, + "traceId": queued.trace_id, + "contextScope": queued.context_summary.get("contextScope").cloned().unwrap_or(Value::Null) + }) +} + +fn remove_queued_run(session_id: &str, queue_id: &str) -> bool { + let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue"); + let Some(entries) = queue.get_mut(session_id) else { + return false; + }; + let before = entries.len(); + entries.retain(|item| item.queue_id != queue_id); + let removed = entries.len() != before; + if entries.is_empty() { + queue.remove(session_id); + } + removed +} + +fn pop_next_queued_run(session_id: &str) -> Option { + let mut queue = HERMES_RUN_QUEUE.lock().expect("hermes run queue"); + let entries = queue.get_mut(session_id)?; + let next = entries.pop_front(); + if entries.is_empty() { + queue.remove(session_id); + } + next +} + +fn extract_run_message(payload: &Value) -> Option { + payload + .get("message") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + payload + .get("messages") + .and_then(Value::as_array) + .and_then(|messages| messages.last()) + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn queue_context_summary(payload: &Value) -> Value { + let page_context = payload.get("pageContext").unwrap_or(&Value::Null); + json!({ + "contextScope": payload.get("contextScope").cloned().unwrap_or(Value::Null), + "selectedBlockId": payload.get("selectedBlockId").cloned().unwrap_or(Value::Null), + "hasSelectedText": payload.get("selectedText").and_then(Value::as_str).map(|value| !value.trim().is_empty()).unwrap_or(false), + "hasPageContext": !page_context.is_null(), + "pageContextKeys": page_context.as_object().map(|object| object.keys().take(12).cloned().collect::>()).unwrap_or_default() + }) +} + fn normalize_success_payload(context: &RequestContext, payload: Value) -> Value { if payload.get("ok").is_some() { payload @@ -670,18 +2179,21 @@ fn upstream_error(context: &RequestContext, status: reqwest::StatusCode, text: S ), _ => (StatusCode::BAD_GATEWAY, "hermes_client_upstream_error"), }; + let suggestions = hermes_settings_suggestions(Some(status.as_u16() as u64), Some(&text)); WebError::new( response_status, code, format!( - "Hermes upstream 返回 HTTP {}: {}", + "Hermes upstream 返回 HTTP {}: {}。建议:{}", status.as_u16(), - text.chars().take(600).collect::() + text.chars().take(600).collect::(), + suggestions.join(";") ), ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + .with_header("x-hermes-client-suggestion", suggestions.join(" | ")) } fn hermes_unconfigured( @@ -770,7 +2282,9 @@ mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::Request; - use std::sync::{Mutex, OnceLock}; + use axum::routing::{get, post}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex, OnceLock}; use tower::util::ServiceExt; fn env_lock() -> &'static Mutex<()> { @@ -778,6 +2292,22 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + fn runtime_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn clear_runtime_registry() { + HERMES_RUNTIME_REGISTRY + .lock() + .expect("hermes runtime registry") + .clear(); + } + + fn clear_run_queue() { + HERMES_RUN_QUEUE.lock().expect("hermes run queue").clear(); + } + fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), @@ -800,6 +2330,162 @@ mod tests { })) } + #[tokio::test] + async fn hermes_client_gateway_health_reports_unconfigured_profile_settings() { + let _env_guard = env_lock().lock().expect("env lock"); + std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); + std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); + std::env::remove_var("MNOTE_HERMES_API_BASE_URL"); + let hermes_home = std::env::temp_dir().join(format!( + "mnote-web-hermes-health-unconfigured-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&hermes_home); + fs::create_dir_all(&hermes_home).expect("hermes home"); + std::env::set_var("HERMES_HOME", &hermes_home); + + let response = app() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/hermes/client/gateway/health?profile=default") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .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["ok"], false); + assert_eq!(payload["gateway"]["status"], "unconfigured"); + assert_eq!(payload["profile"]["modelConfigured"], false); + let suggestions = payload["suggestions"].to_string(); + assert!(suggestions.contains("MNOTE_WEB_HERMES_UPSTREAM_URL")); + assert!(suggestions.contains("model.default")); + + std::env::remove_var("HERMES_HOME"); + let _ = fs::remove_dir_all(&hermes_home); + } + + #[tokio::test] + async fn hermes_client_gateway_health_reports_profile_model_and_api_key_gaps() { + let _env_guard = env_lock().lock().expect("env lock"); + let hermes_home = std::env::temp_dir().join(format!( + "mnote-web-hermes-health-profile-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&hermes_home); + let profile_dir = hermes_home.join("profiles").join("chemist"); + fs::create_dir_all(&profile_dir).expect("profile dir"); + fs::write( + profile_dir.join("config.yaml"), + "model:\n provider: openai\nproviders:\n openai:\n base_url: https://api.openai.com/v1\n", + ) + .expect("profile config"); + std::env::set_var("HERMES_HOME", &hermes_home); + + let mock = axum::Router::new().route( + "/v1/models", + get(|| async { (StatusCode::BAD_REQUEST, "missing model.default and API key") }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let upstream = format!("http://{}", listener.local_addr().expect("addr")); + tokio::spawn(async move { + axum::serve(listener, mock).await.expect("mock upstream"); + }); + std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream); + + let response = app() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/hermes/client/gateway/health?profile=chemist") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .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["ok"], false); + assert_eq!(payload["gateway"]["status"], "upstream_error"); + assert_eq!(payload["profile"]["name"], "chemist"); + assert_eq!(payload["profile"]["modelConfigured"], false); + assert_eq!(payload["profile"]["apiKeyConfigured"], false); + let suggestions = payload["suggestions"].to_string(); + assert!(suggestions.contains("model.default")); + assert!(suggestions.contains("API key")); + + std::env::remove_var("HERMES_HOME"); + std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); + let _ = fs::remove_dir_all(&hermes_home); + } + + #[tokio::test] + async fn hermes_client_upstream_error_message_points_to_hermes_settings() { + let _env_guard = env_lock().lock().expect("env lock"); + let _runtime_guard = runtime_lock().lock().expect("runtime lock"); + clear_runtime_registry(); + clear_run_queue(); + let mock = axum::Router::new().route( + "/v1/runs", + post(|| async { (StatusCode::BAD_REQUEST, "missing model.default and API key") }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let upstream = format!("http://{}", listener.local_addr().expect("addr")); + tokio::spawn(async move { + axum::serve(listener, mock).await.expect("mock upstream"); + }); + std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream); + std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/runs") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from( + json!({ + "documentId": "doc_error", + "sessionId": "mnote_doc_error_trace_1", + "profile": "chemist", + "message": "测试错误提示", + "traceId": "trace_error_1" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + 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["code"], "hermes_client_upstream_error"); + let message = payload["message"].as_str().unwrap_or_default(); + assert!(message.contains("model.default")); + assert!(message.contains("API key")); + assert!(message.contains("Hermes 设置")); + + std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); + } + #[tokio::test] async fn hermes_client_unauthenticated_requests_return_401() { let response = app() @@ -925,6 +2611,442 @@ mod tests { assert!(payload.get("messages").is_none()); } + #[tokio::test] + async fn hermes_client_resume_returns_runtime_state_without_chat_store() { + let _guard = runtime_lock().lock().expect("runtime lock"); + clear_runtime_registry(); + clear_run_queue(); + let registration = HermesRunRegistration { + session_id: "mnote_doc_1_trace_1".into(), + profile: "chemist".into(), + document_id: "doc_1".into(), + trace_id: "trace_1".into(), + }; + let runtime = register_runtime_from_create_run_response( + ®istration, + &json!({ + "ok": true, + "upstream": {"run_id": "run_1"} + }), + ) + .expect("runtime"); + assert_eq!(runtime["sessionId"], "mnote_doc_1_trace_1"); + assert_eq!(runtime["runId"], "run_1"); + assert!(runtime.get("messages").is_none()); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/sessions/mnote_doc_1_trace_1/resume") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .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["ok"], true); + assert_eq!( + payload["session"]["messages"].as_array().map(Vec::len), + Some(0) + ); + assert_eq!(payload["runtime"]["sessionId"], "mnote_doc_1_trace_1"); + assert_eq!(payload["runtime"]["runId"], "run_1"); + assert_eq!(payload["runtime"]["status"], "running"); + } + + #[test] + fn hermes_client_active_session_queues_second_run_without_chat_truth() { + let _guard = runtime_lock().lock().expect("runtime lock"); + clear_runtime_registry(); + clear_run_queue(); + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/client/runs".parse().expect("uri"), + &HeaderMap::new(), + ); + let registration = HermesRunRegistration { + session_id: "mnote_doc_queue_trace_1".into(), + profile: "chemist".into(), + document_id: "doc_queue".into(), + trace_id: "trace_queued_1".into(), + }; + register_runtime_from_create_run_response( + ®istration, + &json!({ + "ok": true, + "upstream": {"run_id": "run_active_1"} + }), + ) + .expect("runtime"); + assert!(session_has_active_run("mnote_doc_queue_trace_1")); + + let queued = enqueue_run( + &context, + ®istration, + &json!({ + "workspaceId": "ws_1", + "documentId": "doc_queue", + "sessionId": "mnote_doc_queue_trace_1", + "profile": "chemist", + "message": "第二条问题", + "traceId": "trace_queued_1", + "selectedText": "选区", + "pageContext": { + "title": "页面标题", + "body": "正文不应进入 queue 响应", + "blocks": [{"id": "block_1"}] + }, + "messages": [ + {"role": "assistant", "content": "历史消息不应保存为 mnote 真相"} + ] + }), + ) + .expect("queued"); + assert_eq!(queued["queued"], true); + assert_eq!(queued["queueLength"], 1); + assert_eq!(queued["sessionId"], "mnote_doc_queue_trace_1"); + assert!(queued.get("messages").is_none()); + assert!(queued.get("pageContext").is_none()); + assert_eq!(queued["contextSummary"]["hasPageContext"], true); + assert_eq!(queued["contextSummary"]["hasSelectedText"], true); + + let popped = pop_next_queued_run("mnote_doc_queue_trace_1").expect("queued run"); + assert_eq!(popped.input, "第二条问题"); + assert_eq!(popped.profile, "chemist"); + assert!(popped.context_summary.get("pageContext").is_none()); + assert_eq!(queue_len_for_session("mnote_doc_queue_trace_1"), 0); + } + + #[test] + fn hermes_client_cancel_queued_run_removes_pending_item() { + let _guard = runtime_lock().lock().expect("runtime lock"); + clear_runtime_registry(); + clear_run_queue(); + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/client/runs".parse().expect("uri"), + &HeaderMap::new(), + ); + let registration = HermesRunRegistration { + session_id: "mnote_doc_cancel_trace_1".into(), + profile: "default".into(), + document_id: "doc_cancel".into(), + trace_id: "trace_cancel_1".into(), + }; + let queued = enqueue_run( + &context, + ®istration, + &json!({ + "documentId": "doc_cancel", + "sessionId": "mnote_doc_cancel_trace_1", + "message": "等待取消", + "traceId": "trace_cancel_1" + }), + ) + .expect("queued"); + let queue_id = queued["queueId"].as_str().expect("queue id"); + assert_eq!(queue_len_for_session("mnote_doc_cancel_trace_1"), 1); + assert!(remove_queued_run("mnote_doc_cancel_trace_1", queue_id)); + assert_eq!(queue_len_for_session("mnote_doc_cancel_trace_1"), 0); + assert!(pop_next_queued_run("mnote_doc_cancel_trace_1").is_none()); + } + + #[tokio::test] + async fn hermes_client_terminal_run_can_start_next_queued_run() { + let _env_guard = env_lock().lock().expect("env lock"); + let _runtime_guard = runtime_lock().lock().expect("runtime lock"); + clear_runtime_registry(); + clear_run_queue(); + + let hits = Arc::new(AtomicUsize::new(0)); + let hits_for_route = Arc::clone(&hits); + let mock = axum::Router::new().route( + "/v1/runs", + post(move |Json(_body): Json| { + let hits = Arc::clone(&hits_for_route); + async move { + hits.fetch_add(1, Ordering::SeqCst); + Json(json!({ + "run_id": "run_queued_next", + "trace_id": "trace_queued_next" + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let upstream = format!("http://{}", listener.local_addr().expect("addr")); + tokio::spawn(async move { + axum::serve(listener, mock).await.expect("mock upstream"); + }); + std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream); + std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); + + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/client/runs".parse().expect("uri"), + &HeaderMap::new(), + ); + let registration = HermesRunRegistration { + session_id: "mnote_doc_auto_trace_1".into(), + profile: "chemist".into(), + document_id: "doc_auto".into(), + trace_id: "trace_auto_1".into(), + }; + register_runtime_from_create_run_response( + ®istration, + &json!({ + "ok": true, + "upstream": {"run_id": "run_active_auto"} + }), + ) + .expect("runtime"); + enqueue_run( + &context, + ®istration, + &json!({ + "workspaceId": "ws_1", + "documentId": "doc_auto", + "sessionId": "mnote_doc_auto_trace_1", + "profile": "chemist", + "message": "自动出队问题", + "traceId": "trace_auto_1", + "pageContext": {"title": "自动出队"} + }), + ) + .expect("queued"); + + let (_, terminal_sessions) = normalize_sse_chunk( + "run_active_auto", + &format!( + "data: {}\n\n", + json!({ + "event": "run.completed", + "run_id": "run_active_auto", + "session_id": "mnote_doc_auto_trace_1" + }) + ), + ); + assert_eq!( + terminal_sessions, + vec!["mnote_doc_auto_trace_1".to_string()] + ); + + start_next_queued_run(context, "mnote_doc_auto_trace_1".into()).await; + assert_eq!(hits.load(Ordering::SeqCst), 1); + assert_eq!(queue_len_for_session("mnote_doc_auto_trace_1"), 0); + let runtime = runtime_state_for_session("mnote_doc_auto_trace_1"); + assert_eq!(runtime["runId"], "run_queued_next"); + assert_eq!(runtime["status"], "running"); + + std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); + } + + #[tokio::test] + async fn hermes_client_abort_returns_events_and_continues_queue() { + let _env_guard = env_lock().lock().expect("env lock"); + let _runtime_guard = runtime_lock().lock().expect("runtime lock"); + clear_runtime_registry(); + clear_run_queue(); + + let stop_hits = Arc::new(AtomicUsize::new(0)); + let run_hits = Arc::new(AtomicUsize::new(0)); + let stop_hits_for_route = Arc::clone(&stop_hits); + let run_hits_for_route = Arc::clone(&run_hits); + let mock = axum::Router::new() + .route( + "/v1/runs/{run_id}/stop", + post(move || { + let stop_hits = Arc::clone(&stop_hits_for_route); + async move { + stop_hits.fetch_add(1, Ordering::SeqCst); + Json(json!({"ok": true, "status": "aborted"})) + } + }), + ) + .route( + "/v1/runs", + post(move |Json(_body): Json| { + let run_hits = Arc::clone(&run_hits_for_route); + async move { + run_hits.fetch_add(1, Ordering::SeqCst); + Json(json!({ + "run_id": "run_after_abort", + "trace_id": "trace_after_abort" + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let upstream = format!("http://{}", listener.local_addr().expect("addr")); + tokio::spawn(async move { + axum::serve(listener, mock).await.expect("mock upstream"); + }); + std::env::set_var("MNOTE_WEB_HERMES_UPSTREAM_URL", &upstream); + std::env::remove_var("MNOTE_HERMES_UPSTREAM_URL"); + + let context = RequestContext::from_http_parts( + &axum::http::Method::POST, + &"/api/hermes/client/runs/run_abort_1/abort" + .parse() + .expect("uri"), + &HeaderMap::new(), + ); + let registration = HermesRunRegistration { + session_id: "mnote_doc_abort_trace_1".into(), + profile: "default".into(), + document_id: "doc_abort".into(), + trace_id: "trace_abort_1".into(), + }; + register_runtime_from_create_run_response( + ®istration, + &json!({ + "ok": true, + "upstream": {"run_id": "run_abort_1"} + }), + ) + .expect("runtime"); + enqueue_run( + &context, + ®istration, + &json!({ + "documentId": "doc_abort", + "sessionId": "mnote_doc_abort_trace_1", + "message": "abort 后继续", + "traceId": "trace_abort_1" + }), + ) + .expect("queued"); + + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/client/runs/run_abort_1/abort") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .body(Body::from(json!({"reason":"test_abort"}).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["events"][0]["event"], "abort.started"); + assert_eq!(payload["events"][1]["event"], "abort.completed"); + assert_eq!(stop_hits.load(Ordering::SeqCst), 1); + assert_eq!(run_hits.load(Ordering::SeqCst), 1); + assert_eq!(queue_len_for_session("mnote_doc_abort_trace_1"), 0); + let runtime = runtime_state_for_session("mnote_doc_abort_trace_1"); + assert_eq!(runtime["runId"], "run_after_abort"); + assert_eq!(runtime["status"], "running"); + + std::env::remove_var("MNOTE_WEB_HERMES_UPSTREAM_URL"); + } + + #[test] + fn hermes_client_runtime_registry_tracks_tool_event_summary_only() { + let _guard = runtime_lock().lock().expect("runtime lock"); + clear_runtime_registry(); + clear_run_queue(); + let registration = HermesRunRegistration { + session_id: "mnote_doc_2_trace_2".into(), + profile: "default".into(), + document_id: "doc_2".into(), + trace_id: "trace_2".into(), + }; + register_runtime_from_create_run_response( + ®istration, + &json!({ + "ok": true, + "runId": "run_2" + }), + ) + .expect("runtime"); + let _ = normalize_sse_chunk( + "run_2", + &format!( + "data: {}\n\n", + json!({ + "event": "tool.started", + "run_id": "run_2", + "session_id": "mnote_doc_2_trace_2", + "name": "mnote.page.get", + "toolCallId": "call_1", + "arguments": {"includeBody": false} + }) + ), + ); + let runtime = runtime_state_for_session("mnote_doc_2_trace_2"); + assert_eq!(runtime["status"], "tool_calling"); + assert_eq!(runtime["lastToolName"], "mnote.page.get"); + assert_eq!(runtime["lastToolCallId"], "call_1"); + assert!(runtime.get("arguments").is_none()); + assert!(runtime.get("messages").is_none()); + } + + #[test] + fn hermes_client_normalizes_upstream_sse_events_to_runtime_contract() { + let _guard = runtime_lock().lock().expect("runtime lock"); + clear_runtime_registry(); + clear_run_queue(); + let registration = HermesRunRegistration { + session_id: "mnote_doc_norm_trace_1".into(), + profile: "default".into(), + document_id: "doc_norm".into(), + trace_id: "trace_norm_1".into(), + }; + register_runtime_from_create_run_response( + ®istration, + &json!({ + "ok": true, + "runId": "run_norm_1" + }), + ) + .expect("runtime"); + + let (normalized, terminal_sessions) = normalize_sse_chunk( + "run_norm_1", + &format!( + "data: {}\n\ndata: {}\n\n", + json!({ + "event": "assistant_message", + "run_id": "run_norm_1", + "session_id": "mnote_doc_norm_trace_1", + "text": "hello" + }), + json!({ + "event": "tool.failed", + "run_id": "run_norm_1", + "session_id": "mnote_doc_norm_trace_1", + "toolCallId": "call_failed_1", + "name": "mnote.page.save", + "code": "permission_denied", + "error": "写入被拒绝" + }) + ), + ); + assert!(normalized.contains("event: message.delta")); + assert!(normalized.contains("\"schemaVersion\":\"mnote.hermes_runtime_event.v1\"")); + assert!(normalized.contains("\"event\":\"tool.failed\"")); + assert!(normalized.contains("\"toolCallId\":\"call_failed_1\"")); + assert!(normalized.contains("\"code\":\"permission_denied\"")); + assert!(terminal_sessions.is_empty()); + } + #[test] fn hermes_client_run_body_carries_page_context_into_run_input() { let context = RequestContext::from_http_parts( @@ -980,14 +3102,79 @@ mod tests { } #[tokio::test] - async fn hermes_client_profile_skill_and_memory_routes_exist_with_stable_unconfigured_error() { + async fn hermes_client_profile_skill_and_memory_routes_use_local_bff_without_upstream() { 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 hermes_home = std::env::temp_dir().join(format!( + "mnote-web-hermes-client-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&hermes_home); + for name in ["draft", "installed-demo", "builtin-demo"] { + fs::create_dir_all(hermes_home.join("skills").join("writing").join(name)) + .expect("test skill dir"); + fs::write( + hermes_home + .join("skills") + .join("writing") + .join(name) + .join("SKILL.md"), + format!("# {name}\n\n写作助手"), + ) + .expect("test skill"); + } + fs::create_dir_all(hermes_home.join("skills").join("copied-demo")) + .expect("test copied skill dir"); + fs::write( + hermes_home + .join("skills") + .join("copied-demo") + .join("SKILL.md"), + "# copied-demo\n\n复制技能", + ) + .expect("test copied skill"); + fs::write( + hermes_home.join("skills").join(".bundled_manifest"), + "builtin-demo:abc123\n", + ) + .expect("test bundled manifest"); + fs::create_dir_all(hermes_home.join("skills").join(".hub")).expect("test hub dir"); + fs::write( + hermes_home.join("skills").join(".hub").join("lock.json"), + json!({ + "installed": { + "installed-demo": { + "source": "skills.sh", + "install_path": "writing/installed-demo" + } + } + }) + .to_string(), + ) + .expect("test hub lock"); + fs::write( + hermes_home.join("skills").join(".usage.json"), + json!({ + "draft": { + "created_by": "agent", + "created_at": "2026-05-14T00:00:00Z", + "patch_count": 2 + } + }) + .to_string(), + ) + .expect("test usage"); + std::env::set_var("HERMES_HOME", &hermes_home); + std::env::set_var("MNOTE_WEB_HERMES_BIN", "__missing_hermes_for_test__"); 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/profile-memory?profile=chemist", + None, + ), ("GET", "/api/hermes/client/skills?profile=chemist", None), ( "PUT", @@ -1011,20 +3198,54 @@ mod tests { .uri(uri) .header("content-type", "application/json") .header("x-mnote-actor-id", "user_1") - .body(Body::from( - body.unwrap_or_else(|| json!({})).to_string(), - )) + .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}" - ); + assert_eq!(response.status(), StatusCode::OK, "{uri}"); } + assert_eq!( + fs::read_to_string(hermes_home.join("active_profile")) + .expect("active profile") + .trim(), + "chemist" + ); + assert_eq!( + fs::read_to_string(hermes_home.join("profiles").join("chemist").join("SOUL.md")) + .expect("soul"), + "test" + ); + let request = Request::builder() + .method("GET") + .uri("/api/hermes/client/skills?profile=chemist") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .expect("request"); + let response = app().oneshot(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("skills json"); + let skills = payload["categories"] + .as_array() + .expect("categories") + .iter() + .flat_map(|category| category["skills"].as_array().into_iter().flatten()) + .map(|skill| { + ( + skill["name"].as_str().unwrap_or_default().to_string(), + skill.clone(), + ) + }) + .collect::>(); + assert_eq!(skills["draft"]["origin"], "generated"); + assert_eq!(skills["draft"]["createdBy"], "agent"); + assert_eq!(skills["installed-demo"]["source"], "hub"); + assert_eq!(skills["installed-demo"]["origin"], "installed"); + assert_eq!(skills["builtin-demo"]["source"], "builtin"); + assert_eq!(skills["copied-demo"]["origin"], "copied"); + std::env::remove_var("HERMES_HOME"); + std::env::remove_var("MNOTE_WEB_HERMES_BIN"); + let _ = fs::remove_dir_all(&hermes_home); } } diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 6f064596..9436ccb1 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -17,6 +17,7 @@ mod mindmap_api; mod mindmap_shell; mod onlyoffice; mod query_support; +mod resource_trash; mod search; mod session; mod snapshot_support; @@ -27,7 +28,7 @@ pub(crate) mod web_shell; mod ws; use crate::app::AppState; -use axum::routing::{any, get, post, put}; +use axum::routing::{any, delete, get, post, put}; use axum::Router; pub fn build_router(state: AppState) -> Router { @@ -37,6 +38,7 @@ pub fn build_router(state: AppState) -> Router { let mut router = Router::new() .route("/health", get(health::health)) .route("/", get(gateway::root_entry)) + .route("/trash", get(gateway::trash_entry)) .route("/favicon.ico", get(gateway::favicon)) .route("/auth", get(gateway::auth_entry).post(gateway::auth_entry)) .route("/search", get(search::shell)) @@ -46,7 +48,14 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/mindmap/{doc_id}/{mindmap_id}", - get(mindmap_api::get_mindmap).post(mindmap_api::apply_mindmap_command), + get(mindmap_api::get_mindmap) + .post(mindmap_api::apply_mindmap_command) + .delete(resource_trash::mindmap_delete) + .patch(resource_trash::mindmap_trash_action), + ) + .route( + "/api/mindmap-trash/empty", + post(resource_trash::mindmap_empty_trash), ) .route( "/documents/{document_id}", @@ -86,13 +95,35 @@ pub fn build_router(state: AppState) -> Router { .route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave)) .route("/api/media/upload", post(media::upload)) .route("/api/media/sign", get(media::sign)) + .route("/api/media/batch", post(resource_trash::media_batch)) + .route("/api/media/purge", post(resource_trash::media_purge)) + .route( + "/api/media/empty-trash", + post(resource_trash::media_empty_trash), + ) + .route("/api/tables/restore", post(resource_trash::table_restore)) + .route("/api/tables/create", post(resource_trash::table_create)) + .route( + "/api/tables/{table_id}", + delete(resource_trash::table_delete), + ) + .route("/api/tables/purge", post(resource_trash::table_purge)) + .route( + "/api/tables/empty-trash", + post(resource_trash::table_empty_trash), + ) .route( "/api/tree/filetree/upload-target-preflight", post(media::filetree_upload_target_preflight), ) + .route( + "/api/tree/filetree/drop-preflight", + post(tree::filetree_drop_preflight), + ) .route("/api/documents/meta", get(documents::meta)) .route("/api/documents/content", get(documents::content)) .route("/api/documents/purge", post(documents::purge)) + .route("/api/documents/empty-trash", post(documents::empty_trash)) .route("/api/documents/title", post(documents::title)) .route("/api/documents/options", post(documents::options)) .route("/api/documents/save", post(documents::save)) @@ -145,6 +176,11 @@ pub fn build_router(state: AppState) -> Router { "/client/sessions/{session_id}", get(hermes_client::get_session), ) + .route( + "/client/sessions/{session_id}/resume", + post(hermes_client::resume_session), + ) + .route("/client/gateway/health", get(hermes_client::gateway_health)) .route("/client/profiles", get(hermes_client::list_profiles)) .route( "/client/profiles/active", @@ -156,18 +192,15 @@ pub fn build_router(state: AppState) -> Router { ) .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), + 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/sessions/{session_id}/queue/{queue_id}", + delete(hermes_client::cancel_queued_run), + ) .route("/client/events/{run_id}", get(hermes_client::stream_events)) .route( "/client/runs/{run_id}/abort", diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 217e63b6..2bca0bf3 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -18,6 +18,7 @@ const SIDEBAR_TREE_JS: &str = r##" var activePageDropRow = null; var draggingFileTreeRowIds = []; var activeFileTreeDropRow = null; + var sidebarFileTreeClipboard = null; var sidebarFileTreeSelection = { selectedRowIds: new Set(), anchorRowId: null, @@ -38,10 +39,15 @@ const SIDEBAR_TREE_JS: &str = r##" pageAiPage: 'chat', pageAiRunStatus: 'idle', pageAiCurrentRunId: '', + pageAiQueueLength: 0, + pageAiQueuedItems: [], + pageAiStoppedRunIds: {}, pageAiAbortController: null, pageAiContextScope: 'page', pageAiTools: [], pageAiToolsError: '', + pageAiGatewayHealth: null, + pageAiGatewayHealthError: '', pageAiLastToolCall: null, pageAiProfiles: [], pageAiActiveProfileName: 'default', @@ -86,9 +92,17 @@ const SIDEBAR_TREE_JS: &str = r##" function currentDocumentId() { var match = window.location.pathname.match(/^\/documents\/([^\/]+)/); + if (!match) match = window.location.pathname.match(/^\/mindmap\/([^\/]+)\/([^\/]+)/); return match ? decodeURIComponent(match[1]) : ''; } + function currentFileTreeActiveRowId() { + var mindmapMatch = window.location.pathname.match(/^\/mindmap\/([^\/]+)\/([^\/]+)/); + if (mindmapMatch) return 'asset:' + decodeURIComponent(mindmapMatch[2]); + var documentId = currentDocumentId(); + return documentId ? 'doc:' + documentId : ''; + } + function currentPageAggregate() { return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {}; } @@ -498,16 +512,33 @@ const SIDEBAR_TREE_JS: &str = r##" function rememberCloudWorkspaceId(workspaceId) { var normalized = String(workspaceId || '').trim(); - if (!normalized || normalized === 'local-folder') return; + if (!normalized || normalized === 'local-folder' || normalized === 'default') return; try { if (window.localStorage) window.localStorage.setItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY, normalized); } catch (_) {} } + function readCurrentCloudWorkspaceIdFromPage() { + if (currentSourceKind() === 'local_folder') return ''; + var params = new URLSearchParams(window.location.search); + var fromUrl = (params.get('workspaceId') || '').trim(); + if (fromUrl && fromUrl !== 'default') return fromUrl; + var fromDom = document.querySelector('[data-workspace-id]'); + if (fromDom instanceof HTMLElement) { + var value = (fromDom.getAttribute('data-workspace-id') || '').trim(); + if (value && value !== 'local-folder' && value !== 'default') return value; + } + return ''; + } + + function rememberCurrentCloudWorkspaceId() { + rememberCloudWorkspaceId(readCurrentCloudWorkspaceIdFromPage()); + } + function readLastCloudWorkspaceId() { try { var stored = window.localStorage ? window.localStorage.getItem(MNOTE_LAST_CLOUD_WORKSPACE_KEY) : ''; - if (stored && stored.trim()) return stored.trim(); + if (stored && stored.trim() && stored.trim() !== 'default') return stored.trim(); } catch (_) {} var params = new URLSearchParams(window.location.search); var fromUrl = (params.get('workspaceId') || '').trim(); @@ -580,7 +611,7 @@ const SIDEBAR_TREE_JS: &str = r##" function openLocalFolderRoot(rootUri) { if (currentSourceKind() !== 'local_folder') { - rememberCloudWorkspaceId(resolveWorkspaceId(document.body)); + rememberCurrentCloudWorkspaceId(); } rememberLocalRoot(rootUri); var targetUrl = new URL('/', window.location.origin); @@ -598,6 +629,88 @@ const SIDEBAR_TREE_JS: &str = r##" window.location.href = targetUrl.toString(); } + function closeTrashModal() { + var modal = document.querySelector('[data-testid="mnote-trash-modal"]'); + if (!modal) return; + var workbench = modal.querySelector('[data-testid="mnote-trash-workbench"]'); + if (workbench && workbench.__mnoteTrashEventSource && typeof workbench.__mnoteTrashEventSource.close === 'function') { + workbench.__mnoteTrashEventSource.close(); + } + var fileScrollTop = Number(modal.getAttribute('data-file-scroll-top') || '0'); + var fileRoot = document.getElementById('sidebar-file-tree-root'); + if (fileRoot instanceof HTMLElement && Number.isFinite(fileScrollTop)) fileRoot.scrollTop = fileScrollTop; + if (modal.parentElement) modal.parentElement.removeChild(modal); + document.documentElement.removeAttribute('data-mnote-trash-modal-open'); + } + + function executeTrashWorkbenchScripts(parsed) { + parsed.querySelectorAll('script').forEach(function(script) { + var nextScript = document.createElement('script'); + Array.from(script.attributes || []).forEach(function(attr) { + nextScript.setAttribute(attr.name, attr.value); + }); + nextScript.textContent = script.textContent || ''; + document.body.appendChild(nextScript); + if (nextScript.parentElement) nextScript.parentElement.removeChild(nextScript); + }); + } + + function renderLocalFolderTrashPlaceholder(content, rootUri) { + content.innerHTML = [ + '
', + '

本地文件夹垃圾箱

', + '

本地删除项保存在当前目录的 .mnote/trash.mnote/trash-index.json 中。

', + '
当前弹窗已保持在本地文件夹上下文:' + escapeHtml(rootUri || '未选择本地目录') + '
', + '
' + ].join(''); + } + + function openTrashModal(trigger) { + var existing = document.querySelector('[data-testid="mnote-trash-modal"]'); + if (existing) return; + var fileRoot = document.getElementById('sidebar-file-tree-root'); + var activeRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-selected="true"], #sidebar-tree-root .tree-row[data-active="true"]'); + var overlay = document.createElement('div'); + overlay.className = 'mnote-trash-modal'; + overlay.setAttribute('data-testid', 'mnote-trash-modal'); + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', '垃圾箱'); + overlay.setAttribute('data-file-scroll-top', fileRoot instanceof HTMLElement ? String(fileRoot.scrollTop) : '0'); + if (activeRow instanceof HTMLElement) { + overlay.setAttribute('data-active-row-id', activeRow.getAttribute('data-row-id') || activeRow.getAttribute('data-node-id') || ''); + } + overlay.innerHTML = '
' + + '
' + + '' + + '
正在加载垃圾箱...
' + + '
'; + document.body.appendChild(overlay); + document.documentElement.setAttribute('data-mnote-trash-modal-open', 'true'); + var content = overlay.querySelector('[data-testid="mnote-trash-modal-content"]'); + var sourceKind = currentSourceKind(); + if (sourceKind === 'local_folder') { + renderLocalFolderTrashPlaceholder(content, new URLSearchParams(window.location.search).get('rootUri') || ''); + return; + } + var workspaceId = resolveWorkspaceId(trigger || document.body); + var url = new URL('/trash', window.location.origin); + if (workspaceId) url.searchParams.set('workspaceId', workspaceId); + fetch(url.toString(), { headers: { 'x-mnote-trash-modal': '1' } }).then(function(response) { + return response.text().then(function(html) { + if (!response.ok) throw new Error('trash_modal_load_failed_' + response.status); + var parsed = new DOMParser().parseFromString(html, 'text/html'); + var workbench = parsed.querySelector('[data-testid="mnote-trash-workbench"]'); + if (!workbench) throw new Error('trash_modal_missing_workbench'); + content.innerHTML = ''; + content.appendChild(workbench); + executeTrashWorkbenchScripts(parsed); + }); + }).catch(function(error) { + content.innerHTML = '
' + escapeHtml(error && error.message ? error.message : String(error)) + '
'; + }); + } + function closeLocalFolderDialog() { var existing = document.querySelector('[data-testid="mnote-local-folder-dialog"]'); if (existing && existing.parentElement) existing.parentElement.removeChild(existing); @@ -933,7 +1046,7 @@ const SIDEBAR_TREE_JS: &str = r##" document.querySelectorAll('.tree-row[data-node-id="' + cssEscape(nodeId) + '"], .tree-row[data-document-id="' + cssEscape(nodeId) + '"], .tree-row[data-doc-id="' + cssEscape(nodeId) + '"]').forEach(function(row) { if (row instanceof HTMLElement) { if (row.getAttribute('data-shell-mode') === 'filetree') { - row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === 'index:' + nodeId)); + row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === 'doc:' + nodeId)); } else { row.setAttribute('data-active', 'true'); } @@ -1023,18 +1136,58 @@ const SIDEBAR_TREE_JS: &str = r##" if (!documentId) return; var escaped = cssEscape(documentId); var escapedDocRowId = cssEscape('doc:' + documentId); - var selectors = [ + var pageSelectors = [ '.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title', - '.tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title', '.wolai-page-row[data-node-id="' + escaped + '"] > .wolai-row-title', 'a[href="/documents/' + escaped + '"] > .wolai-row-title', 'a[href^="/documents/' + escaped + '?"] > .wolai-row-title' ]; - selectors.forEach(function(selector) { + pageSelectors.forEach(function(selector) { document.querySelectorAll(selector).forEach(function(node) { if (node instanceof HTMLElement) node.textContent = title; }); }); + document.querySelectorAll('.tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title').forEach(function(node) { + if (node instanceof HTMLElement) node.textContent = fileTreePageTitle(title); + }); + } + + function fileTreePageTitle(title) { + var normalized = String(title || '无标题').trim() || '无标题'; + return normalized.endsWith('.md') ? normalized : normalized + '.md'; + } + + function isFileTreePageRow(row) { + if (!(row instanceof HTMLElement)) return false; + if (row.getAttribute('data-asset-id')) return false; + var rowKind = row.getAttribute('data-row-kind') || ''; + return rowKind === 'document' || rowKind === 'doc' || rowKind === 'index' || rowKind === 'markdown'; + } + + function normalizeFileTreePageRenameTitle(value) { + var normalized = String(value || '').trim(); + if (/\.md$/i.test(normalized)) normalized = normalized.slice(0, -3).trim(); + return normalized; + } + + function validateFileTreeRename(row, draft) { + var raw = String(draft || '').trim(); + if (!raw) return '名称不能为空'; + if (/[\\/:*?"<>|]/.test(raw)) return '文件名不能包含 / \\ : * ? " < > |'; + if (!isFileTreePageRow(row)) return ''; + var pageTitle = normalizeFileTreePageRenameTitle(raw); + if (!pageTitle) return '名称不能为空'; + var expectedFileName = fileTreePageTitle(pageTitle).toLocaleLowerCase(); + var rowId = row.getAttribute('data-row-id') || ''; + var parentId = row.getAttribute('data-parent-id') || ''; + var siblings = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-kind="document"], #sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-kind="doc"], #sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-kind="markdown"]')); + var duplicate = siblings.some(function(sibling) { + if (!(sibling instanceof HTMLElement)) return false; + if ((sibling.getAttribute('data-row-id') || '') === rowId) return false; + if ((sibling.getAttribute('data-parent-id') || '') !== parentId) return false; + return fileTreePageTitle(normalizeFileTreePageRenameTitle(rowTitle(sibling))).toLocaleLowerCase() === expectedFileName; + }); + return duplicate ? '同级已存在同名页面' : ''; } function documentIdFromDelta(data) { @@ -1189,7 +1342,6 @@ const SIDEBAR_TREE_JS: &str = r##" if (!targetContainer) return false; var parentNodeId = parentId ? 'doc:' + parentId : ''; var docNodeId = 'doc:' + documentId; - var indexNodeId = 'index:' + documentId; var depth = localFileInsertDepth(parentId); var grouped = new Map(); grouped.set(parentNodeId, [{ @@ -1197,22 +1349,10 @@ const SIDEBAR_TREE_JS: &str = r##" nodeId: docNodeId, rowId: docNodeId, rowKind: 'document', - title: title, + title: fileTreePageTitle(title), documentId: documentId, parentNodeId: parentNodeId, depth: depth, - expandable: true, - childCount: 1 - }]); - grouped.set(docNodeId, [{ - id: indexNodeId, - nodeId: indexNodeId, - rowId: indexNodeId, - rowKind: 'index', - title: 'index.md', - documentId: documentId, - parentNodeId: docNodeId, - depth: depth + 1, expandable: false, childCount: 0 }]); @@ -1369,6 +1509,7 @@ const SIDEBAR_TREE_JS: &str = r##" return String(item.rowKind || 'document') === 'document'; }); var activeId = currentDocumentId(); + var activeRowId = currentFileTreeActiveRowId(); tree.innerHTML = '
    ' + (rows.length ? renderPageRows('', groupRowsByParent(rows), activeId, 0) : '
  • 暂无页面
  • ') + '
'; return true; } @@ -1413,21 +1554,38 @@ const SIDEBAR_TREE_JS: &str = r##" return String(item && (item.iconHint || item.rowKind || 'file') || 'file').trim() || 'file'; } - function renderFileRows(parentId, grouped, activeId) { + function isFileTreeProjectionPageRow(rowKind, assetId) { + if (assetId) return false; + return rowKind === 'document' || rowKind === 'doc' || rowKind === 'markdown'; + } + + function normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity) { + var title = String(rawTitle || '').trim(); + var isMindmap = iconKind === 'mindmap' || String(objectIdentity && objectIdentity.objectKind || '') === 'mindmap'; + var generated = /^mindmap[-_]/i.test(title); + if (!isMindmap || (!generated && title.length <= 24)) return title || '无标题'; + return shortMindmapFileName(assetId || title); + } + + function renderFileRows(parentId, grouped, activeId, activeRowId) { return (grouped.get(parentId) || []).map(function(item) { var nodeId = nodeIdOf(item); var rowId = rowIdOf(item); var rowKind = String(item.rowKind || 'document'); - var title = titleOf(item); + var rawTitle = titleOf(item); var depth = Number(item.depth || 0); var parent = parentIdOf(item); var documentId = fileDocumentId(item); var assetId = fileAssetId(item); var objectIdentity = fileObjectIdentity(item); + var iconKind = iconKindOf(item); + var title = isFileTreeProjectionPageRow(rowKind, assetId) + ? fileTreePageTitle(rawTitle) + : normalizeMindmapFileTreeTitle(rawTitle, assetId, iconKind, objectIdentity); var children = grouped.get(nodeId) || []; var expandable = Boolean(item.expandable || item.childCount > 0 || children.length); var expanded = expandable && item.expandedByDefault !== false; - var selected = rowId === 'index:' + activeId; + var selected = activeRowId ? rowId === activeRowId : rowId === 'doc:' + activeId; var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row'; var toggle = expandable ? '' @@ -1436,9 +1594,9 @@ const SIDEBAR_TREE_JS: &str = r##" ? '' : ''; var childHtml = expandable - ? '
    ' + renderFileRows(nodeId, grouped, activeId) + '
' + ? '
    ' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '
' : ''; - return '
  • ' + toggle + '
    ' + createAction + '
    ' + childHtml + '
  • '; + return '
  • ' + toggle + '
    ' + createAction + '
    ' + childHtml + '
  • '; }).join(''); } @@ -1447,7 +1605,8 @@ const SIDEBAR_TREE_JS: &str = r##" if (!tree) return false; var rows = projectionItems(projection); var activeId = currentDocumentId(); - tree.innerHTML = '
      ' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId) : '
    • 暂无文件或页面
    • ') + '
    '; + var activeRowId = currentFileTreeActiveRowId(); + tree.innerHTML = '
      ' + (rows.length ? renderFileRows('', groupRowsByParent(rows), activeId, activeRowId) : '
    • 暂无文件或页面
    • ') + '
    '; return true; } @@ -1458,6 +1617,71 @@ const SIDEBAR_TREE_JS: &str = r##" return renderedPage || renderedFile; } + function replaceSidebarTreeFromDocument(nextDocument, rootId) { + var current = document.getElementById(rootId); + var next = nextDocument ? nextDocument.getElementById(rootId) : null; + if (!(current instanceof HTMLElement) || !(next instanceof HTMLElement)) return false; + current.innerHTML = next.innerHTML; + Array.from(next.attributes || []).forEach(function(attr) { + current.setAttribute(attr.name, attr.value); + }); + return true; + } + + async function refreshLocalFolderSidebarSnapshot() { + var response = await fetch(window.location.href, { headers: { accept: 'text/html' } }); + if (!response.ok) return false; + var html = await response.text(); + var nextDocument = new DOMParser().parseFromString(html, 'text/html'); + var renderedPage = replaceSidebarTreeFromDocument(nextDocument, 'sidebar-tree-root'); + var renderedFile = replaceSidebarTreeFromDocument(nextDocument, 'sidebar-file-tree-root'); + if (!renderedPage && !renderedFile) return false; + syncSidebarFileTreeSelection(); + restoreSidebarTreeTab(); + document.documentElement.setAttribute('data-mnote-local-folder-watch-applied', 'snapshot'); + return true; + } + + function startLocalFolderSidebarWatch() { + var params = new URLSearchParams(window.location.search); + if ((params.get('sourceKind') || '').trim() !== 'local_folder') return; + var rootUri = (params.get('rootUri') || '').trim(); + if (!rootUri) return; + var revision = ''; + var refreshTimer = 0; + var scheduleRefresh = function() { + if (refreshTimer) return; + refreshTimer = window.setTimeout(function() { + refreshTimer = 0; + void refreshLocalFolderSidebarSnapshot(); + }, 180); + }; + var poll = async function() { + if (document.hidden) return; + var url = new URL('/api/tree/local-folder-watch', window.location.origin); + url.searchParams.set('rootUri', rootUri); + var response = await fetch(url.toString(), { headers: { accept: 'application/json' } }); + if (!response.ok) return; + var payload = await response.json(); + var nextRevision = payload && payload.result && typeof payload.result.revision === 'string' + ? payload.result.revision + : ''; + if (!nextRevision) return; + if (!revision) { + revision = nextRevision; + return; + } + if (nextRevision !== revision) { + revision = nextRevision; + scheduleRefresh(); + } + }; + window.setInterval(function() { + void poll(); + }, 1200); + void poll(); + } + function isTitleOnlyDocumentPatch(candidate) { if (!candidate || typeof candidate !== 'object') return false; var allowedKeys = { @@ -1552,6 +1776,43 @@ const SIDEBAR_TREE_JS: &str = r##" return '/mindmap/' + encodeURIComponent(doc) + '/' + encodeURIComponent(map); } + function navigateToMindmapObject(documentId, assetId, workspaceId) { + var mindmapPath = buildMindmapOpenPath(documentId, assetId); + if (!mindmapPath) return false; + var targetUrl = new URL(mindmapPath, window.location.origin); + var url = targetUrl.pathname + targetUrl.search; + if (mnoteNavigationInFlight === url) return true; + if (typeof window.__mnoteDocumentPaneRuntime?.openPrimaryMindmap === 'function') { + mnoteNavigationInFlight = url; + document.documentElement.setAttribute('data-mnote-navigation-pending', 'true'); + document.documentElement.setAttribute('data-mnote-navigation-target', assetId); + window.__mnoteDocumentPaneRuntime.openPrimaryMindmap({ + documentId: documentId, + mindmapId: assetId, + workspaceId: workspaceId || '', + url: targetUrl + }).then(function(){ + if (mnoteNavigationInFlight === url) mnoteNavigationInFlight = ''; + document.documentElement.removeAttribute('data-mnote-navigation-pending'); + }).catch(function(error){ + console.warn('mnote mindmap pane 内导航失败,将回退整页导航', error); + if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') { + window.__mnoteTreeLiveEventSource.close(); + } + window.location.assign(url); + }); + return true; + } + mnoteNavigationInFlight = url; + document.documentElement.setAttribute('data-mnote-navigation-pending', 'true'); + document.documentElement.setAttribute('data-mnote-navigation-target', assetId); + if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') { + window.__mnoteTreeLiveEventSource.close(); + } + window.location.assign(url); + return true; + } + function isMindmapAssetDetail(detail) { var assetId = String(detail && detail.assetId || '').trim(); var assetType = String(detail && detail.assetType || '').trim(); @@ -1590,12 +1851,9 @@ const SIDEBAR_TREE_JS: &str = r##" if (!assetId) return; var documentId = String(detail && detail.documentId || '').trim(); if (isMindmapAssetDetail(detail) && documentId) { - var mindmapPath = buildMindmapOpenPath(documentId, assetId); - if (mindmapPath) { - document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-object-shell'); - document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); - window.location.assign(mindmapPath); - } + document.documentElement.setAttribute('data-mnote-last-mindmap-asset-open-mode', 'mindmap-object-shell'); + document.documentElement.setAttribute('data-mnote-last-mindmap-asset-id', assetId); + navigateToMindmapObject(documentId, assetId, String(detail.workspaceId || '').trim()); return; } try { @@ -2083,7 +2341,7 @@ const SIDEBAR_TREE_JS: &str = r##" var docId = String(documentId || currentDocumentId() || '').trim(); var assetId = String(mindmapId || '').trim(); if (!docId || !assetId) return null; - var fileName = 'mindmap-' + assetId + '.json'; + var fileName = shortMindmapFileName(assetId); return { id: assetId, document_id: docId, @@ -2093,6 +2351,13 @@ const SIDEBAR_TREE_JS: &str = r##" }; } + function shortMindmapFileName(mindmapId) { + var raw = String(mindmapId || '').trim(); + var digits = raw.match(/(\d{4,})$/); + var suffix = digits ? digits[1].slice(-4) : raw.replace(/^mindmap[_-]?/i, '').slice(-6); + return suffix ? '思维导图-' + suffix + '.json' : '思维导图.json'; + } + function parseMindmapApiTarget(input) { try { var rawUrl = typeof input === 'string' @@ -2348,6 +2613,124 @@ const SIDEBAR_TREE_JS: &str = r##" return { x: rect.left + Math.min(rect.width - 12, 180), y: rect.top + Math.min(rect.height, 22) }; } + async function renameFileTreeAsset(assetId, title) { + var response = await fetch('/api/media/batch', { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'rename', assetIds: [assetId], newName: title }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error((payload && payload.error) || '重命名附件失败'); + return payload; + } + + function beginFileTreeInlineRename(row) { + if (!(row instanceof HTMLElement)) return false; + if (row.querySelector('.tree-rename-input')) return true; + var rowKind = row.getAttribute('data-row-kind') || ''; + var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || ''; + var assetId = row.getAttribute('data-asset-id') || ''; + if (!documentId && !assetId) return false; + if (rowKind && ['document', 'doc', 'index', 'markdown', 'asset'].indexOf(rowKind) < 0 && !assetId) return false; + var link = row.querySelector(':scope > .tree-link'); + var title = rowTitle(row); + if (!(link instanceof HTMLElement)) return false; + link.hidden = true; + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'tree-rename-input'; + input.setAttribute('data-rename-id', row.getAttribute('data-row-id') || ''); + input.value = title; + input.style.minWidth = '0'; + input.style.flex = '1 1 auto'; + input.style.height = '22px'; + input.style.border = '1px solid #93c5fd'; + input.style.borderRadius = '3px'; + input.style.padding = '0 4px'; + input.style.font = 'inherit'; + input.style.background = '#fff'; + input.style.color = '#1f2937'; + var validation = document.createElement('div'); + validation.setAttribute('data-testid', 'tree-rename-validation'); + validation.setAttribute('data-mnote-rename-validation', 'true'); + validation.style.fontSize = '12px'; + validation.style.color = '#dc2626'; + validation.style.padding = '2px 4px'; + validation.hidden = true; + var setValidation = function(message) { + var text = String(message || '').trim(); + validation.textContent = text; + validation.hidden = !text; + input.setAttribute('aria-invalid', text ? 'true' : 'false'); + }; + var closed = false; + var committing = false; + var close = function() { + if (closed) return; + closed = true; + if (input.parentElement) input.parentElement.removeChild(input); + if (validation.parentElement) validation.parentElement.removeChild(validation); + link.hidden = false; + }; + var commit = function() { + if (closed || committing) return; + var nextTitle = input.value.trim(); + if (!nextTitle || nextTitle === title) { + close(); + return; + } + var validationMessage = validateFileTreeRename(row, nextTitle); + if (validationMessage) { + setValidation(validationMessage); + return; + } + setValidation(''); + var commandTitle = isFileTreePageRow(row) ? normalizeFileTreePageRenameTitle(nextTitle) : nextTitle; + committing = true; + input.disabled = true; + var work = assetId + ? Promise.resolve().then(function(){ + document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-asset-id="' + cssEscape(assetId) + '"] .tree-link-title').forEach(function(titleNode) { + titleNode.textContent = commandTitle; + }); + close(); + return renameFileTreeAsset(assetId, commandTitle); + }).then(function(){ return null; }) + : dispatchTreeCommand(row, { + action: 'rename', + workspaceId: resolveWorkspaceId(row), + documentId: documentId, + title: commandTitle + }).then(function(){ updateTitleEverywhere(documentId, commandTitle); }); + void work.then(close).catch(function(error) { + committing = false; + input.disabled = false; + window.alert(error && error.message ? error.message : '重命名失败'); + }); + }; + input.addEventListener('click', function(event) { event.stopPropagation(); }); + input.addEventListener('dblclick', function(event) { event.stopPropagation(); }); + input.addEventListener('keydown', function(event) { + event.stopPropagation(); + if (event.key === 'Enter') { + event.preventDefault(); + commit(); + } else if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + }); + input.addEventListener('blur', commit); + link.parentElement.insertBefore(input, link.nextSibling); + link.parentElement.insertBefore(validation, input.nextSibling); + window.requestAnimationFrame(function() { + input.focus(); + input.select(); + }); + return true; + } + function closeTreeContextMenu() { if (activeTreeContextMenu && activeTreeContextMenu.parentElement) { activeTreeContextMenu.parentElement.removeChild(activeTreeContextMenu); @@ -2466,11 +2849,45 @@ const SIDEBAR_TREE_JS: &str = r##" void copyTreeContextValue(documentId || detail.assetId || detail.rowId || '', 'copy-id'); return; } + if (action === 'new-file') { + void createPage(trigger || document.body, documentId || null); + return; + } + if (action === 'copy-path') { + void copyTreeContextValue(title, 'copy-path'); + return; + } + if (action === 'refresh') { + document.documentElement.setAttribute('data-mnote-filetree-refresh-requested', 'true'); + dispatchSidebarEvent('tree.filetree.refresh', detail); + return; + } + if (action === 'collapse-all') { + document.querySelectorAll('#sidebar-file-tree-root .tree-row[aria-expanded="true"]').forEach(function(row) { + if (!(row instanceof HTMLElement)) return; + row.setAttribute('aria-expanded', 'false'); + var node = row.closest('.tree-node'); + var children = node ? node.querySelector(':scope > .tree-children') : null; + if (children) children.classList.add('tree-children--collapsed'); + }); + return; + } + if (action === 'reveal') { + if (trigger && typeof trigger.scrollIntoView === 'function') { + trigger.scrollIntoView({ block: 'nearest' }); + trigger.focus && trigger.focus(); + } + return; + } if (action === 'duplicate') { dispatchSidebarEvent('tree.page.duplicate', detail); return; } if (action === 'rename') { + if (detail.contextKind === 'filetree' && trigger) { + var renameRow = trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger; + if (beginFileTreeInlineRename(renameRow)) return; + } var nextTitle = window.prompt('重命名页面', title); if (nextTitle && nextTitle.trim() && documentId) { void dispatchTreeCommand(trigger || document.body, { @@ -2493,7 +2910,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (action === 'delete-trash' && documentId) { if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return; void dispatchTreeCommand(trigger || document.body, { - action: 'purge', + action: 'archive', workspaceId: workspaceId, documentId: documentId }); @@ -2514,6 +2931,7 @@ const SIDEBAR_TREE_JS: &str = r##" button.setAttribute('role', 'menuitem'); button.setAttribute('data-action', item.action); button.disabled = item.disabled === true; + if (item.title) button.title = item.title; var icon = document.createElement('span'); icon.className = 'material-symbols-outlined mnote-tree-context-menu__icon'; icon.setAttribute('aria-hidden', 'true'); @@ -2566,7 +2984,32 @@ const SIDEBAR_TREE_JS: &str = r##" { action: 'color', icon: 'format_paint', label: '颜色' } ] : isAsset ? [ { action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' }, - { action: 'copy-id', icon: 'tag', label: '复制资源 ID' } + { action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' }, + { action: 'copy-id', icon: 'tag', label: '复制资源 ID' }, + { separator: true }, + { action: 'copy-path', icon: 'content_copy', label: 'Copy Path' }, + { action: 'refresh', icon: 'refresh', label: 'Refresh' }, + { action: 'reveal', icon: 'my_location', label: 'Reveal' } + ] : kind === 'filetree' ? [ + { action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' }, + { separator: true }, + { action: 'copy-link', icon: 'link', label: '复制访问链接' }, + { action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' }, + { action: 'copy-id', icon: 'tag', label: '复制页面ID' }, + { action: 'copy-path', icon: 'content_copy', label: 'Copy Path' }, + { separator: true }, + { action: 'new-file', icon: 'note_add', label: 'New File' }, + { action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: true, title: 'Convex 文件夹对象尚未进入正式 tree command' }, + { action: 'paste-into', icon: 'content_paste', label: 'Paste Into', disabled: true, title: '请使用 Ctrl/Cmd+V 粘贴;右键 Paste Into 待接 selection target' }, + { action: 'refresh', icon: 'refresh', label: 'Refresh' }, + { action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' }, + { action: 'reveal', icon: 'my_location', label: 'Reveal' }, + { separator: true }, + { action: 'duplicate', icon: 'file_copy', label: '拷贝副本' }, + { separator: true }, + { action: 'rename', icon: 'edit', label: '重命名', shortcut: 'F2' }, + { separator: true }, + { action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true } ] : [ { action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' }, { separator: true }, @@ -2676,6 +3119,280 @@ const SIDEBAR_TREE_JS: &str = r##" return rowId ? [rowId] : []; } + function selectedSidebarFileTreeRows() { + var selectedIds = sidebarFileTreeSelection.selectedRowIds; + var rows = visibleFileTreeRows().filter(function(row) { + var rowId = row.getAttribute('data-row-id') || ''; + return rowId && selectedIds.has(rowId); + }); + if (rows.length > 0) return rows; + if (sidebarFileTreeSelection.focusedRowId) { + var focused = document.querySelector( + '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]' + ); + if (focused instanceof HTMLElement) return [focused]; + } + return []; + } + + function fileTreeRowDocumentId(row) { + if (!(row instanceof HTMLElement)) return ''; + return String(row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '').trim(); + } + + function fileTreeRowAssetId(row) { + if (!(row instanceof HTMLElement)) return ''; + var assetId = String(row.getAttribute('data-asset-id') || '').trim(); + if (assetId) return assetId; + if (currentSourceKind() === 'local_folder' && fileTreeRowKind(row) === 'asset') { + return String(row.getAttribute('data-row-id') || '').trim(); + } + return ''; + } + + function fileTreeRowKind(row) { + if (!(row instanceof HTMLElement)) return ''; + return String(row.getAttribute('data-row-kind') || '').trim(); + } + + function hasSelectedDocumentAncestor(row, selectedDocRowIds) { + var node = row instanceof HTMLElement ? row.closest('.tree-node') : null; + while (node && node.parentElement) { + var parentChildren = node.parentElement.closest('.tree-children'); + var parentNode = parentChildren ? parentChildren.closest('.tree-node') : null; + var parentRow = parentNode ? parentNode.querySelector(':scope > .tree-row[data-shell-mode="filetree"]') : null; + if (parentRow instanceof HTMLElement) { + var parentRowId = parentRow.getAttribute('data-row-id') || ''; + if (selectedDocRowIds.has(parentRowId)) return true; + } + node = parentNode; + } + return false; + } + + function classifySidebarFileTreeAsset(row) { + var badge = row instanceof HTMLElement ? row.querySelector('.tree-kind-badge') : null; + var iconKind = badge instanceof HTMLElement ? String(badge.getAttribute('data-kind') || '').trim() : ''; + var objectKind = row instanceof HTMLElement ? String(row.getAttribute('data-object-kind') || '').trim() : ''; + var title = rowTitle(row).toLowerCase(); + if (objectKind === 'mindmap' || iconKind === 'mindmap') return 'mindmap'; + if (objectKind === 'table' || iconKind === 'table' || iconKind === 'luckysheet' || title.indexOf('.luckysheet') >= 0) return 'table'; + return 'file'; + } + + function buildSidebarFileTreeDeletePlan(rows) { + var docRows = []; + var assetRows = []; + rows.forEach(function(row) { + var kind = fileTreeRowKind(row); + if ((kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown') && fileTreeRowDocumentId(row)) { + docRows.push(row); + return; + } + if (fileTreeRowAssetId(row)) assetRows.push(row); + }); + var selectedDocRowIds = new Set(docRows.map(function(row) { return row.getAttribute('data-row-id') || ''; }).filter(Boolean)); + var topDocRows = []; + var seenDocs = new Set(); + docRows.forEach(function(row) { + var documentId = fileTreeRowDocumentId(row); + if (!documentId || seenDocs.has(documentId)) return; + if (hasSelectedDocumentAncestor(row, selectedDocRowIds)) return; + seenDocs.add(documentId); + topDocRows.push(row); + }); + var selectedTopDocRows = new Set(topDocRows.map(function(row) { return row.getAttribute('data-row-id') || ''; }).filter(Boolean)); + var fileAssetRows = []; + var mindmapRows = []; + var tableRows = []; + var seenAssets = new Set(); + assetRows.forEach(function(row) { + var assetId = fileTreeRowAssetId(row); + if (!assetId || seenAssets.has(assetId)) return; + if (hasSelectedDocumentAncestor(row, selectedTopDocRows)) return; + seenAssets.add(assetId); + var assetKind = classifySidebarFileTreeAsset(row); + if (assetKind === 'mindmap') mindmapRows.push(row); + else if (assetKind === 'table') tableRows.push(row); + else fileAssetRows.push(row); + }); + return { + docRows: topDocRows, + fileAssetRows: fileAssetRows, + mindmapRows: mindmapRows, + tableRows: tableRows + }; + } + + function sidebarFileTreeDeleteConfirmText(plan) { + var docCount = plan.docRows.length; + var fileCount = plan.fileAssetRows.length; + var mindmapCount = plan.mindmapRows.length; + var tableCount = plan.tableRows.length; + var parts = []; + if (docCount > 0) parts.push(docCount + ' 个页面(删除到垃圾桶)'); + if (fileCount > 0) parts.push(fileCount + ' 个附件(删除,10 分钟内可撤销)'); + if (mindmapCount > 0) parts.push(mindmapCount + ' 个思维导图(移入垃圾桶,10 分钟内可恢复)'); + if (tableCount > 0) parts.push(tableCount + ' 个在线表格(删除)'); + return '确认删除选中的 ' + parts.join(' + ') + ' 吗?'; + } + + async function postSidebarFileTreeJson(url, body) { + var response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body || {}) + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) { + throw new Error((payload && (payload.error || payload.message)) || (url + ' failed: ' + response.status)); + } + return payload; + } + + function fileTreeRowsByRowIds(rowIds) { + return (rowIds || []).map(function(rowId) { + return document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(rowId) + '"]'); + }).filter(function(row) { return row instanceof HTMLElement; }); + } + + function fileTreeChildCount(documentId) { + if (!documentId) return 0; + var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + documentId) + '"]'); + var node = row ? row.closest('.tree-node') : null; + var children = node ? node.querySelectorAll(':scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="document"], :scope > .tree-children > .tree-node > .tree-row[data-shell-mode="filetree"][data-row-kind="doc"]') : []; + return children.length; + } + + async function pasteSidebarFileTreeClipboard(trigger) { + if (!sidebarFileTreeClipboard || !Array.isArray(sidebarFileTreeClipboard.rowIds) || sidebarFileTreeClipboard.rowIds.length === 0) return false; + var targetRow = trigger instanceof HTMLElement ? trigger : null; + if (!targetRow && sidebarFileTreeSelection.focusedRowId) { + targetRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(sidebarFileTreeSelection.focusedRowId) + '"]'); + } + var targetDocumentId = fileTreeRowDocumentId(targetRow) || currentDocumentId(); + if (!targetDocumentId) return false; + var action = sidebarFileTreeClipboard.action === 'cut' ? 'move' : 'copy'; + var rows = fileTreeRowsByRowIds(sidebarFileTreeClipboard.rowIds); + if (rows.length === 0) return false; + var plan = buildSidebarFileTreeDeletePlan(rows); + var workspaceId = resolveWorkspaceId(targetRow || document.body); + var failures = []; + if (action === 'copy') { + dispatchSidebarEvent('tree.filetree.copy-requested', { rowIds: sidebarFileTreeClipboard.rowIds, targetDocumentId: targetDocumentId }); + return true; + } + for (var i = 0; i < plan.docRows.length; i += 1) { + var docRow = plan.docRows[i]; + var documentId = fileTreeRowDocumentId(docRow); + if (!documentId || documentId === targetDocumentId) continue; + try { + await dispatchTreeCommand(targetRow || docRow, { + action: 'move', + workspaceId: workspaceId, + documentId: documentId, + parentId: targetDocumentId, + sortOrder: fileTreeChildCount(targetDocumentId) + i + }); + } catch (error) { + failures.push(documentId + ': ' + (error && error.message ? error.message : '移动页面失败')); + } + } + var assetIds = plan.fileAssetRows.map(fileTreeRowAssetId).filter(Boolean); + if (assetIds.length > 0) { + try { + await postSidebarFileTreeJson('/api/media/batch', { + action: 'move', + assetIds: assetIds, + targetDocumentId: targetDocumentId + }); + } catch (error) { + failures.push(assetIds.join(',') + ': ' + (error && error.message ? error.message : '移动附件失败')); + } + } + if (failures.length > 0) { + window.alert('部分对象移动失败:' + failures.join(';')); + return false; + } + sidebarFileTreeClipboard = null; + return true; + } + + async function deleteSelectedSidebarFileTreeRows(trigger) { + var rows = selectedSidebarFileTreeRows(); + var plan = buildSidebarFileTreeDeletePlan(rows); + var total = plan.docRows.length + plan.fileAssetRows.length + plan.mindmapRows.length + plan.tableRows.length; + if (total === 0) return false; + if (!window.confirm(sidebarFileTreeDeleteConfirmText(plan))) return false; + var failures = []; + for (var i = 0; i < plan.docRows.length; i += 1) { + var docRow = plan.docRows[i]; + var documentId = fileTreeRowDocumentId(docRow); + try { + await dispatchTreeCommand(trigger || docRow, { + action: 'archive', + workspaceId: resolveWorkspaceId(docRow), + documentId: documentId + }); + applyRemoveDocumentDelta({ documentId: documentId }); + } catch (error) { + failures.push(documentId); + } + } + if (plan.fileAssetRows.length > 0) { + var fileAssetIds = plan.fileAssetRows.map(fileTreeRowAssetId).filter(Boolean); + try { + if (currentSourceKind() === 'local_folder') { + for (var lf = 0; lf < fileAssetIds.length; lf += 1) { + await dispatchTreeCommand(trigger || plan.fileAssetRows[lf], { + action: 'archive', + workspaceId: resolveWorkspaceId(plan.fileAssetRows[lf]), + documentId: fileAssetIds[lf] + }); + } + } else { + await postSidebarFileTreeJson('/api/media/batch', { action: 'delete', assetIds: fileAssetIds }); + } + fileAssetIds.forEach(removeFileTreeAssetRow); + } catch (error) { + failures = failures.concat(fileAssetIds); + } + } + for (var m = 0; m < plan.mindmapRows.length; m += 1) { + var mindmapRow = plan.mindmapRows[m]; + var mindmapId = fileTreeRowAssetId(mindmapRow); + try { + var mindmapDocId = fileTreeRowDocumentId(mindmapRow); + var mindmapResponse = await fetch('/api/mindmap/' + encodeURIComponent(mindmapDocId) + '/' + encodeURIComponent(mindmapId), { method: 'DELETE' }); + if (!mindmapResponse.ok) throw new Error('mindmap_delete_failed_' + mindmapResponse.status); + removeFileTreeAssetRow(mindmapId); + } catch (error) { + failures.push(mindmapId); + } + } + for (var t = 0; t < plan.tableRows.length; t += 1) { + var tableRow = plan.tableRows[t]; + var tableId = fileTreeRowAssetId(tableRow); + try { + var tableResponse = await fetch('/api/tables/' + encodeURIComponent(tableId), { method: 'DELETE' }); + if (!tableResponse.ok) throw new Error('table_delete_failed_' + tableResponse.status); + removeFileTreeAssetRow(tableId); + window.dispatchEvent(new CustomEvent('online-table-deleted', { detail: { tableId: tableId } })); + } catch (error) { + failures.push(tableId); + } + } + sidebarFileTreeSelection.selectedRowIds = new Set(); + sidebarFileTreeSelection.focusedRowId = null; + syncSidebarFileTreeSelection(); + if (failures.length > 0) { + window.alert('部分对象删除失败:' + failures.slice(0, 5).join(', ') + (failures.length > 5 ? '…' : '')); + return false; + } + document.documentElement.setAttribute('data-mnote-filetree-bulk-delete-applied', 'true'); + return true; + } + function ensureSearchModal() { var existing = document.querySelector('[data-testid="wolai-search-modal"]'); if (existing instanceof HTMLElement) return existing; @@ -3149,8 +3866,7 @@ const SIDEBAR_TREE_JS: &str = r##" }); var payload = await response.json().catch(function(){ return null; }); if (!response.ok || !payload || payload.ok !== true) { - var code = payload && payload.code ? payload.code : 'hermes_session_failed_' + response.status; - throw new Error(code); + throw new Error(pageAiErrorMessage(payload, 'hermes_session_failed_' + response.status)); } var session = { id: String(payload.sessionId || '').trim(), @@ -3170,17 +3886,22 @@ const SIDEBAR_TREE_JS: &str = r##" async function pageAiRestoreHermesSession() { var current = pageAiCurrentSession(); if (!current || !String(current.id || '').startsWith('mnote_')) return; - var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id), { + var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume', { + method: 'POST', headers: { 'accept': 'application/json' } }); if (!response.ok) return; 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 : []; + pageAiApplyRuntimeState(payload && payload.runtime); if (session && (session.profile || session.profileName)) { current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile()); } - if (!messages.length) return; + if (!messages.length) { + renderPageAiControls(); + return; + } pageUiState.pageAiMessages = messages.slice(-40).map(function(message) { return { role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'), @@ -3190,6 +3911,7 @@ const SIDEBAR_TREE_JS: &str = r##" current.messages = pageUiState.pageAiMessages.slice(); current.updatedAt = Date.now(); renderPageAiConversation(); + renderPageAiControls(); } function pageAiCurrentSession() { @@ -3295,6 +4017,9 @@ const SIDEBAR_TREE_JS: &str = r##" description: String(skill && skill.description || '').trim(), enabled: skill && skill.enabled !== false, source: String(skill && skill.source || 'local').trim() || 'local', + origin: String(skill && skill.origin || '').trim(), + createdBy: String(skill && skill.createdBy || '').trim(), + patchCount: Number(skill && skill.patchCount || 0), modified: Boolean(skill && skill.modified) }; }) @@ -3306,6 +4031,9 @@ const SIDEBAR_TREE_JS: &str = r##" description: String(skill && skill.description || '').trim(), enabled: skill && skill.enabled !== false, source: String(skill && skill.source || 'local').trim() || 'local', + origin: String(skill && skill.origin || '').trim(), + createdBy: String(skill && skill.createdBy || '').trim(), + patchCount: Number(skill && skill.patchCount || 0), modified: Boolean(skill && skill.modified) }; }) @@ -3322,6 +4050,9 @@ const SIDEBAR_TREE_JS: &str = r##" description: skill.description, enabled: skill.enabled !== false, source: skill.source || 'local', + origin: skill.origin || '', + createdBy: skill.createdBy || '', + patchCount: Number(skill.patchCount || 0), modified: Boolean(skill.modified) }); }); @@ -3344,6 +4075,137 @@ const SIDEBAR_TREE_JS: &str = r##" } } + function pageAiApplyRuntimeState(runtime) { + if (!runtime || typeof runtime !== 'object') return; + var status = String(runtime.status || '').trim(); + var runId = String(runtime.runId || runtime.run_id || '').trim(); + if (status) pageAiSetRunStatus(status, runId); + var queueLength = Number(runtime.queueLength || runtime.queue_length || 0); + if (Number.isFinite(queueLength)) pageUiState.pageAiQueueLength = Math.max(0, queueLength); + var toolName = String(runtime.lastToolName || runtime.last_tool_name || '').trim(); + if (toolName) { + pageUiState.pageAiLastToolCall = { + event: String(runtime.lastEvent || runtime.last_event || ''), + name: toolName, + runId: runId, + traceId: String(runtime.traceId || runtime.trace_id || ''), + auditId: String(runtime.lastAuditId || runtime.last_audit_id || '') + }; + } + } + + function pageAiApplyQueuedRun(payload) { + if (!payload || payload.queued !== true) return false; + var queueId = String(payload.queueId || payload.queue_id || '').trim(); + var queueLength = Number(payload.queueLength || payload.queue_length || 0); + pageUiState.pageAiQueueLength = Number.isFinite(queueLength) ? Math.max(0, queueLength) : 1; + if (queueId) { + pageUiState.pageAiQueuedItems = pageUiState.pageAiQueuedItems.filter(function(item) { + return item.queueId !== queueId; + }).concat([{ + queueId: queueId, + sessionId: String(payload.sessionId || payload.session_id || pageUiState.pageAiActiveSessionId || ''), + traceId: String(payload.traceId || payload.trace_id || ''), + queuedAt: Number(payload.queuedAt || payload.queued_at || Date.now()) + }]); + } + pageAiSetRunStatus('queued', pageUiState.pageAiCurrentRunId); + return true; + } + + function pageAiPreviewValue(value) { + if (value == null || value === '') return ''; + if (typeof value === 'string') return value.length > 120 ? value.slice(0, 120) + '…' : value; + try { + var text = JSON.stringify(value); + return text.length > 120 ? text.slice(0, 120) + '…' : text; + } catch (_) { + return String(value); + } + } + + function pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId) { + var toolEvent = null; + try { + toolEvent = JSON.parse(payloadText || 'null'); + } catch (_) { + toolEvent = {}; + } + var toolName = String(toolEvent && (toolEvent.name || toolEvent.tool || toolEvent.toolName || eventName) || eventName); + var toolCallId = String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || (runId + ':' + toolName)); + var status = eventName === 'tool.completed' ? 'completed' : (eventName === 'tool.failed' ? 'failed' : 'running'); + var traceId = String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''); + var auditId = String(toolEvent && (toolEvent.audit_id || toolEvent.auditId) || ''); + var argsSummary = pageAiPreviewValue(toolEvent && (toolEvent.arguments || toolEvent.args || toolEvent.input)); + var resultSource = toolEvent && (toolEvent.summary || toolEvent.result || toolEvent.output); + if (!resultSource && status === 'failed') { + resultSource = [toolEvent && toolEvent.code, toolEvent && toolEvent.error].filter(Boolean).join(' '); + } + var resultSummary = pageAiPreviewValue(resultSource); + pageUiState.pageAiLastToolCall = { + event: eventName, + name: toolName, + runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId), + traceId: traceId, + auditId: auditId + }; + var existing = pageUiState.pageAiMessages.find(function(item) { + return item.role === 'tool' && item.toolCallId === toolCallId; + }); + if (!existing) { + existing = { + role: 'tool', + content: toolName, + toolCallId: toolCallId, + toolName: toolName, + status: status, + argsSummary: '', + resultSummary: '', + traceId: traceId, + auditId: auditId + }; + pageUiState.pageAiMessages.push(existing); + } + existing.content = toolName; + existing.toolName = toolName; + existing.status = status; + existing.traceId = traceId || existing.traceId || ''; + existing.auditId = auditId || existing.auditId || ''; + if (argsSummary) existing.argsSummary = argsSummary; + if (resultSummary) existing.resultSummary = resultSummary; + } + + async function pageAiCancelQueuedRun(queueId) { + queueId = String(queueId || '').trim(); + if (!queueId) return; + var item = pageUiState.pageAiQueuedItems.find(function(entry) { + return entry.queueId === queueId; + }); + var sessionId = String(item && item.sessionId || pageUiState.pageAiActiveSessionId || '').trim(); + if (!sessionId) return; + try { + var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/queue/' + encodeURIComponent(queueId), { + method: 'DELETE', + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'queue_cancel_failed_' + response.status)); + pageUiState.pageAiQueuedItems = pageUiState.pageAiQueuedItems.filter(function(entry) { + return entry.queueId !== queueId; + }); + var queueLength = Number(payload && (payload.queueLength || payload.queue_length || 0)); + pageUiState.pageAiQueueLength = Number.isFinite(queueLength) ? Math.max(0, queueLength) : pageUiState.pageAiQueuedItems.length; + pageUiState.pageAiMessages.push({ role: 'assistant', content: '已取消一条 Hermes 队列项。' }); + } catch (error) { + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: '取消 Hermes 队列项失败:' + (error instanceof Error ? error.message : String(error)) + }); + } + renderPageAiConversation(); + renderPageAiControls(); + } + function pageAiSetContextScope(scope) { var next = String(scope || '').trim() || 'page'; pageUiState.pageAiContextScope = next; @@ -3405,6 +4267,23 @@ const SIDEBAR_TREE_JS: &str = r##" }).filter(Boolean); } + function pageAiErrorMessage(payload, fallback) { + if (!payload || typeof payload !== 'object') return fallback; + return String(payload.message || payload.error || payload.code || fallback || '').trim() || fallback; + } + + function pageAiNormalizeGatewayHealth(payload) { + var upstream = pageAiUnwrapUpstream(payload) || payload || {}; + return { + ok: Boolean(upstream.ok), + profile: upstream.profile || null, + gateway: upstream.gateway || null, + suggestions: pageAiNormalizeArray(upstream.suggestions).map(function(item) { + return String(item || '').trim(); + }).filter(Boolean) + }; + } + function pageAiSetDraftForSection(section, value) { pageUiState.pageAiProfileMemoryDrafts[section] = String(value == null ? '' : value); } @@ -3429,7 +4308,7 @@ const SIDEBAR_TREE_JS: &str = r##" headers: { 'accept': 'application/json' } }); var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'tools_failed_' + response.status); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tools_failed_' + response.status)); pageUiState.pageAiTools = pageAiNormalizeTools(payload); pageUiState.pageAiToolsError = ''; } catch (error) { @@ -3438,9 +4317,25 @@ const SIDEBAR_TREE_JS: &str = r##" renderPageAiControls(); } + async function pageAiLoadGatewayHealth() { + try { + var response = await fetch('/api/hermes/client/gateway/health?profile=' + encodeURIComponent(pageAiCurrentProfile()), { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'gateway_health_failed_' + response.status)); + pageUiState.pageAiGatewayHealth = pageAiNormalizeGatewayHealth(payload); + pageUiState.pageAiGatewayHealthError = ''; + } catch (error) { + pageUiState.pageAiGatewayHealthError = error instanceof Error ? error.message : String(error); + } + renderPageAiControls(); + } + async function pageAiStopRun() { var runId = String(pageUiState.pageAiCurrentRunId || '').trim(); if (!runId || pageUiState.pageAiRunStatus === 'idle' || pageUiState.pageAiRunStatus === 'completed') return; + pageUiState.pageAiStoppedRunIds[runId] = true; try { var response = await fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/abort', { method: 'POST', @@ -3452,7 +4347,8 @@ const SIDEBAR_TREE_JS: &str = r##" }) }); var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(payload && payload.code ? payload.code : 'run_abort_failed_' + response.status); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'run_abort_failed_' + response.status)); + pageAiApplyRuntimeState(payload && payload.runtime); pageAiSetRunStatus('aborted', runId); pageUiState.pageAiMessages.push({ role: 'assistant', content: '已请求 Hermes 停止当前 run。' }); } catch (error) { @@ -3472,22 +4368,36 @@ const SIDEBAR_TREE_JS: &str = r##" 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; + || String(skill.category || '').toLowerCase().indexOf(query) >= 0 + || pageAiSkillOriginLabel(skill).toLowerCase().indexOf(query) >= 0; }); } + function pageAiSkillOriginLabel(skill) { + var origin = String(skill && skill.origin || '').trim(); + if (origin === 'generated' || String(skill && skill.createdBy || '') === 'agent') return '生成'; + if (origin === 'installed') return '安装'; + if (origin === 'builtin') return '内置'; + if (origin === 'copied') return '本地'; + var source = String(skill && skill.source || '').trim(); + if (source === 'hub') return '安装'; + if (source === 'builtin') return '内置'; + return '本地'; + } + 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); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, '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 = ''; + void pageAiLoadGatewayHealth(); } catch (error) { pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'default', active: true }]; @@ -3510,7 +4420,7 @@ const SIDEBAR_TREE_JS: &str = r##" 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); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_switch_failed_' + response.status)); pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(session) { return session && session.profile === next; }); @@ -3520,6 +4430,7 @@ const SIDEBAR_TREE_JS: &str = r##" await pageAiEnsureHermesSession(true); await pageAiLoadProfileMemory(); await pageAiLoadSkills(); + await pageAiLoadGatewayHealth(); renderPageAiControls(); } catch (error) { pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); @@ -3533,7 +4444,7 @@ const SIDEBAR_TREE_JS: &str = r##" 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); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_memory_failed_' + response.status)); pageAiApplyProfileMemory(payload); pageUiState.pageAiProfileMemoryError = ''; } catch (error) { @@ -3558,7 +4469,7 @@ const SIDEBAR_TREE_JS: &str = r##" }) }); 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); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_memory_save_failed_' + response.status)); pageUiState.pageAiProfileMemory[normalized] = content; pageUiState.pageAiProfileMemoryError = ''; document.documentElement.setAttribute('data-mnote-page-ai-memory-saved', normalized); @@ -3575,7 +4486,7 @@ const SIDEBAR_TREE_JS: &str = r##" 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); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skills_failed_' + response.status)); pageUiState.pageAiSkills = pageAiNormalizeSkills(payload); pageUiState.pageAiSkillError = ''; } catch (error) { @@ -3604,7 +4515,7 @@ const SIDEBAR_TREE_JS: &str = r##" }) }); 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); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, '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); @@ -3658,7 +4569,10 @@ const SIDEBAR_TREE_JS: &str = r##" profileSelect.value = activeProfile; } var runStatus = drawer.querySelector('[data-page-ai-run-status]'); - if (runStatus instanceof HTMLElement) runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus); + if (runStatus instanceof HTMLElement) { + var queueSuffix = pageUiState.pageAiQueueLength > 0 ? ' · 队列 ' + pageUiState.pageAiQueueLength : ''; + runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus) + queueSuffix; + } var stopButton = drawer.querySelector('[data-page-ai-action="stop-run"]'); if (stopButton instanceof HTMLButtonElement) { var canStop = ['queued', 'running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0 && pageUiState.pageAiCurrentRunId; @@ -3670,6 +4584,21 @@ const SIDEBAR_TREE_JS: &str = r##" settingsLink.disabled = !pageAiHermesSettingsUrl(); settingsLink.title = pageAiHermesSettingsUrl() ? '打开 Hermes 设置' : '未配置 MNOTE_WEB_HERMES_UPSTREAM_URL'; } + var queueList = drawer.querySelector('[data-page-ai-queue-list]'); + if (queueList instanceof HTMLElement) { + if (!pageUiState.pageAiQueuedItems.length) { + queueList.innerHTML = '
    暂无排队项。
    '; + } else { + queueList.innerHTML = pageUiState.pageAiQueuedItems.map(function(item, index) { + var label = '队列 ' + (index + 1); + return '' + + '
    ' + + '
    ' + escapeHtml(label) + '
    ' + escapeHtml(item.queueId) + '
    ' + + '' + + '
    '; + }).join(''); + } + } var sessionNode = drawer.querySelector('[data-page-ai-session-status]'); if (sessionNode instanceof HTMLElement) { var session = pageAiCurrentSession(); @@ -3738,14 +4667,17 @@ const SIDEBAR_TREE_JS: &str = r##" skillList.innerHTML = '
    没有匹配的 Hermes skill。
    '; } else { skillList.innerHTML = skills.map(function(skill) { - var source = skill.source || 'local'; - var sourceText = source + (skill.modified ? ' · modified' : ''); + var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : ''); + var description = String(skill.description || '').trim(); + var hasDescription = description && description !== '---' && description !== '无描述'; return '' + '
    ' + '
    ' + - '
    ' + escapeHtml(skill.name) + '
    ' + - '
    ' + escapeHtml(skill.description || '无描述') + '
    ' + - '
    ' + escapeHtml(sourceText) + '
    ' + + '
    ' + + '
    ' + escapeHtml(skill.name) + '
    ' + + '
    ' + escapeHtml(sourceText) + '
    ' + + '
    ' + + (hasDescription ? '
    ' + escapeHtml(description) + '
    ' : '') + '
    ' + '
    ' + '' + '' + + '' + + '
    ' + + '
    ' + + 'gateway' + + '未检查' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + 'queue' + + '由 mnote-web 管理' + + '
    ' + + '
    ' + + '
    ' + '
    ' + '
    ' + 'mnote tools' + @@ -3986,6 +4972,20 @@ const SIDEBAR_TREE_JS: &str = r##" } conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) { var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI'); + if (item.role === 'tool') { + var statusLabel = item.status === 'completed' ? '完成' : (item.status === 'failed' ? '失败' : '运行中'); + return '' + + '
    ' + + '
    ' + escapeHtml(roleLabel) + '
    ' + + '
    ' + + '' + escapeHtml(item.toolName || item.content || 'tool') + '' + + '
    ' + escapeHtml(statusLabel) + (item.toolCallId ? ' · ' + escapeHtml(item.toolCallId) : '') + '
    ' + + (item.argsSummary ? '
    参数 ' + escapeHtml(item.argsSummary) + '
    ' : '') + + (item.resultSummary ? '
    结果 ' + escapeHtml(item.resultSummary) + '
    ' : '') + + ((item.traceId || item.auditId) ? '
    ' + escapeHtml([item.traceId, item.auditId].filter(Boolean).join(' · ')) + '
    ' : '') + + '
    ' + + '
    '; + } return '' + '
    ' + '
    ' + escapeHtml(roleLabel) + '
    ' + @@ -4009,7 +5009,8 @@ const SIDEBAR_TREE_JS: &str = r##" pageAiLoadProfiles(), pageAiLoadProfileMemory(), pageAiLoadSkills(), - pageAiLoadTools() + pageAiLoadTools(), + pageAiLoadGatewayHealth() ]).then(function() { renderPageAiControls(); }).catch(function() {}).then(function() { @@ -4071,14 +5072,15 @@ const SIDEBAR_TREE_JS: &str = r##" } async function sendPageAiMessage(text) { - if (pageUiState.pageAiBusy) return; + var allowQueue = ['running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0; + if (pageUiState.pageAiBusy && !allowQueue) return; var prompt = searchText(text); if (!prompt) return; - pageUiState.pageAiBusy = true; + if (!allowQueue) pageUiState.pageAiBusy = true; var currentSession = null; try { await pageAiEnsureHermesSession(); - pageAiSetRunStatus('queued'); + if (!allowQueue) pageAiSetRunStatus('queued'); renderPageAiControls(); var contextSnapshot = currentPageAiContextSnapshot(); var scopedContext = pageAiScopedPageContext(contextSnapshot); @@ -4111,9 +5113,25 @@ const SIDEBAR_TREE_JS: &str = r##" }); if (!response.ok) { var errorPayload = await response.json().catch(function(){ return null; }); - throw new Error(errorPayload && errorPayload.code ? errorPayload.code : 'page_ai_failed_' + response.status); + throw new Error(pageAiErrorMessage(errorPayload, 'page_ai_failed_' + response.status)); } var runPayload = await response.json().catch(function(){ return null; }); + if (pageAiApplyQueuedRun(runPayload)) { + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: '已加入 Hermes 队列,前一条 run 完成后继续处理。' + }); + if (currentSession) { + currentSession.messages = pageUiState.pageAiMessages.slice(); + currentSession.updatedAt = Date.now(); + } + renderPageAiConversation(); + renderPageAiControls(); + return; + } + if (allowQueue) { + throw new Error('hermes_queue_expected_queued_response'); + } 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'); @@ -4125,11 +5143,12 @@ const SIDEBAR_TREE_JS: &str = r##" }); if (!eventResponse.ok) { var eventError = await eventResponse.json().catch(function(){ return null; }); - throw new Error(eventError && eventError.code ? eventError.code : 'hermes_events_failed_' + eventResponse.status); + throw new Error(pageAiErrorMessage(eventError, 'hermes_events_failed_' + eventResponse.status)); } var assistantText = ''; await streamPageAiResponse(eventResponse, function(eventName, payloadText) { - if (eventName === 'assistant_message' || eventName === 'message.delta') { + if (eventName === 'message.delta') { + if (pageUiState.pageAiStoppedRunIds[runId]) return; try { var payload = JSON.parse(payloadText || 'null'); assistantText += searchText((payload && (payload.text || payload.delta)) || ''); @@ -4144,39 +5163,30 @@ const SIDEBAR_TREE_JS: &str = r##" } catch (_) {} pageAiSetRunStatus('completed', runId); } - if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { + if (eventName === 'run.failed') { try { - var toolEvent = JSON.parse(payloadText || 'null'); - pageUiState.pageAiLastToolCall = { - event: eventName, - name: String(toolEvent && (toolEvent.name || toolEvent.tool || eventName) || eventName), - runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId), - traceId: String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId), - auditId: String(toolEvent && (toolEvent.audit_id || toolEvent.auditId) || '') - }; - pageUiState.pageAiMessages.push({ - role: 'tool', - content: pageUiState.pageAiLastToolCall.name - }); - } catch (_) { - pageUiState.pageAiLastToolCall = { - event: eventName, - name: eventName, - runId: runId, - traceId: runTraceId, - auditId: '' - }; - pageUiState.pageAiMessages.push({ role: 'tool', content: eventName }); - } + var failed = JSON.parse(payloadText || 'null'); + assistantText = searchText((failed && (failed.message || failed.code)) || 'Hermes run failed'); + } catch (_) {} + pageAiSetRunStatus('failed', runId); + } + if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') { + pageUiState.pageAiStoppedRunIds[runId] = true; + pageAiSetRunStatus('aborted', runId); + } + if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { + pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId); renderPageAiConversation(); pageAiSetRunStatus('tool_calling', runId); renderPageAiControls(); } }); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: humanizePageAiResponse(assistantText, prompt) - }); + if (!pageUiState.pageAiStoppedRunIds[runId]) { + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: humanizePageAiResponse(assistantText, prompt) + }); + } currentSession = pageAiCurrentSession(); if (currentSession) { currentSession.messages = pageUiState.pageAiMessages.slice(); @@ -4194,7 +5204,7 @@ const SIDEBAR_TREE_JS: &str = r##" currentSession.updatedAt = Date.now(); } } finally { - pageUiState.pageAiBusy = false; + if (!allowQueue) pageUiState.pageAiBusy = false; renderPageAiConversation(); renderPageAiControls(); } @@ -4781,6 +5791,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (pageAiTab) { e.preventDefault(); pageUiState.pageAiPage = pageAiTab.getAttribute('data-page-ai-tab') || 'chat'; + if (pageUiState.pageAiPage === 'runtime') void pageAiLoadGatewayHealth(); renderPageAiControls(); renderPageAiConversation(); return; @@ -4859,6 +5870,13 @@ const SIDEBAR_TREE_JS: &str = r##" return; } + var cancelQueuedRun = closestAction(e.target, '[data-page-ai-action="cancel-queued-run"]'); + if (cancelQueuedRun) { + e.preventDefault(); + void pageAiCancelQueuedRun(cancelQueuedRun.getAttribute('data-page-ai-queue-id')); + return; + } + var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]'); if (searchTrigger) { e.preventDefault(); @@ -4882,6 +5900,19 @@ const SIDEBAR_TREE_JS: &str = r##" var sourceMenu = closestAction(e.target, '[data-testid="mnote-workspace-source-menu"]'); if (!sourceMenu) closeWorkspaceSourceMenu(); + var trashTrigger = closestAction(e.target, '[data-mnote-action="open-trash-modal"]'); + if (trashTrigger) { + e.preventDefault(); + openTrashModal(trashTrigger); + return; + } + var trashClose = closestAction(e.target, '[data-mnote-trash-modal-close]'); + if (trashClose) { + e.preventDefault(); + closeTrashModal(); + return; + } + var tabTrigger = closestAction(e.target, '[data-mnote-sidebar-tree-tab]'); if (tabTrigger) { e.preventDefault(); @@ -5020,6 +6051,42 @@ const SIDEBAR_TREE_JS: &str = r##" }); document.addEventListener('keydown', function(event) { + var keyTarget = event.target; + var fileTreeRootForKey = document.getElementById('sidebar-file-tree-root'); + var fileTreeRowForKey = closestAction(keyTarget, '.tree-row[data-shell-mode="filetree"]'); + if (!fileTreeRowForKey && fileTreeRootForKey && fileTreeRootForKey.contains(document.activeElement)) { + fileTreeRowForKey = closestAction(document.activeElement, '.tree-row[data-shell-mode="filetree"]'); + } + if (fileTreeRowForKey && event.key === 'F2') { + event.preventDefault(); + beginFileTreeInlineRename(fileTreeRowForKey); + return; + } + if (fileTreeRowForKey && (event.ctrlKey || event.metaKey) && !event.altKey && event.key) { + var shortcutKey = event.key.toLowerCase(); + if (shortcutKey === 'c' || shortcutKey === 'x') { + var selectedRows = selectedSidebarFileTreeRows(); + var selectedRowIds = selectedRows.map(function(row) { return row.getAttribute('data-row-id') || ''; }).filter(Boolean); + if (selectedRowIds.length > 0) { + event.preventDefault(); + sidebarFileTreeClipboard = { action: shortcutKey === 'x' ? 'cut' : 'copy', rowIds: selectedRowIds }; + document.documentElement.setAttribute('data-mnote-filetree-clipboard-action', sidebarFileTreeClipboard.action); + } + return; + } + if (shortcutKey === 'v') { + event.preventDefault(); + void pasteSidebarFileTreeClipboard(fileTreeRowForKey); + return; + } + } + if (event.key === 'Delete' || event.key === 'Backspace') { + if (fileTreeRowForKey) { + event.preventDefault(); + void deleteSelectedSidebarFileTreeRows(fileTreeRowForKey); + return; + } + } if (event.key === 'Escape' && isPageSettingsOpen()) { event.preventDefault(); closePageSettingsPopover(); @@ -5031,6 +6098,7 @@ const SIDEBAR_TREE_JS: &str = r##" return; } if (event.key === 'Escape') { + closeTrashModal(); closeSearchModal(); closeTreeContextMenu(); } @@ -5200,7 +6268,8 @@ const SIDEBAR_TREE_JS: &str = r##" if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false'); activeFileTreeDropRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]') || fileTree; if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'true'); - if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles || event.altKey ? 'copy' : 'move'; + var copyModifier = event.altKey || event.ctrlKey || event.metaKey; + if (event.dataTransfer) event.dataTransfer.dropEffect = hasFiles || copyModifier ? 'copy' : 'move'; } }); @@ -5248,7 +6317,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (files.length) { dispatchSidebarEvent('tree.filetree.external-drop', Object.assign({}, detail, { files: files })); } else { - dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: event.altKey === true })); + dispatchSidebarEvent('tree.filetree.internal-drop', Object.assign({}, detail, { rowIds: rowIds, copy: event.altKey === true || event.ctrlKey === true || event.metaKey === true })); } draggingFileTreeRowIds = []; } @@ -5276,6 +6345,17 @@ const SIDEBAR_TREE_JS: &str = r##" applyCreatedDocumentLocally(detail.result || {}, body.parentId || null, body.title || '新页面'); return; } + if (action === 'rename' && body.documentId && body.title) { + updateTitleEverywhere(body.documentId, body.title); + document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'rename'); + return; + } + if (action === 'move' && body.documentId) { + if (applyMoveDocumentDelta({ documentId: body.documentId, parentId: body.parentId || null })) { + document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'move'); + } + return; + } if ((action === 'purge' || action === 'delete' || action === 'archive') && body.documentId) { if (applyRemoveDocumentDelta({ documentId: body.documentId })) { document.documentElement.setAttribute('data-mnote-tree-local-command-applied', 'remove'); @@ -5350,6 +6430,7 @@ const SIDEBAR_TREE_JS: &str = r##" }); syncSidebarFileTreeSelection(); restoreSidebarTreeTab(); + startLocalFolderSidebarWatch(); })(); "##; @@ -5626,6 +6707,8 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("applyCreatedDocumentLocally")); assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'create")); assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'remove")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'rename")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-tree-local-command-applied', 'move")); assert!(SIDEBAR_TREE_JS.contains("wolai:assets-changed")); assert!(SIDEBAR_TREE_JS.contains("applyAssetsChangedToFileTree")); assert!(SIDEBAR_TREE_JS.contains("installMindmapAssetFetchObserver")); @@ -5645,7 +6728,9 @@ mod tests { assert!(!SIDEBAR_TREE_JS.contains("openMindmapAssetInDocumentShell")); assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-mindmap-asset-open-mode")); assert!(SIDEBAR_TREE_JS.contains("mindmap-object-shell")); - assert!(SIDEBAR_TREE_JS.contains("window.location.assign(mindmapPath)")); + assert!(SIDEBAR_TREE_JS.contains("navigateToMindmapObject(documentId, assetId")); + assert!(SIDEBAR_TREE_JS.contains("__mnoteDocumentPaneRuntime?.openPrimaryMindmap")); + assert!(SIDEBAR_TREE_JS.contains("shortMindmapFileName")); assert!(SIDEBAR_TREE_JS.contains("assetType: assetType || null")); assert!(SIDEBAR_TREE_JS.contains("data-object-identity")); assert!(SIDEBAR_TREE_JS.contains("readFileTreeObjectIdentity")); @@ -5659,6 +6744,10 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("window.open(buildOnlyOfficeOpenUrl")); assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop")); assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop")); + assert!(SIDEBAR_TREE_JS.contains("beginFileTreeInlineRename")); + assert!(SIDEBAR_TREE_JS.contains("tree-rename-input")); + assert!(SIDEBAR_TREE_JS.contains("sidebarFileTreeClipboard")); + assert!(SIDEBAR_TREE_JS.contains("pasteSidebarFileTreeClipboard")); assert!(SIDEBAR_TREE_JS.contains("MNOTE_RECENT_LOCAL_ROOTS_KEY")); assert!(SIDEBAR_TREE_JS.contains("requestOpenLocalFolder")); assert!(SIDEBAR_TREE_JS.contains("sourceKind', 'local_folder")); @@ -5667,6 +6756,28 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("mnote-recent-local-root")); } + #[test] + fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() { + assert!( + !SIDEBAR_TREE_JS.contains("rememberCloudWorkspaceId(resolveWorkspaceId(document.body))"), + "进入本地文件夹前不能用 document.body 推导云空间 workspaceId;它会在无 workspaceId URL 时回退成 default" + ); + assert!( + SIDEBAR_TREE_JS.contains("rememberCurrentCloudWorkspaceId()"), + "进入本地文件夹前应从 URL 或 DOM 子树读取真实云空间 workspaceId" + ); + } + + #[test] + fn sidebar_tree_runtime_polls_local_folder_without_browser_reload() { + assert!(SIDEBAR_TREE_JS.contains("startLocalFolderSidebarWatch")); + assert!(SIDEBAR_TREE_JS.contains("refreshLocalFolderSidebarSnapshot")); + assert!(SIDEBAR_TREE_JS.contains("/api/tree/local-folder-watch")); + assert!(SIDEBAR_TREE_JS.contains("replaceSidebarTreeFromDocument")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-local-folder-watch-applied")); + assert!(!SIDEBAR_TREE_JS.contains("window.location.reload")); + } + #[test] fn sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates() { assert!(SIDEBAR_TREE_JS.contains("openTreeContextMenu")); @@ -5694,6 +6805,57 @@ mod tests { assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/file")); } + #[test] + fn sidebar_filetree_runtime_uses_markdown_page_row_without_local_index_child() { + assert!(SIDEBAR_TREE_JS.contains("fileTreePageTitle(title)")); + assert!(SIDEBAR_TREE_JS.contains("normalizeFileTreePageRenameTitle")); + assert!(SIDEBAR_TREE_JS.contains("validateFileTreeRename")); + assert!(SIDEBAR_TREE_JS.contains("同级已存在同名页面")); + assert!(SIDEBAR_TREE_JS.contains("文件名不能包含")); + assert!(SIDEBAR_TREE_JS.contains("rowId === 'doc:' + activeId")); + assert!(SIDEBAR_TREE_JS.contains("currentFileTreeActiveRowId")); + assert!(SIDEBAR_TREE_JS + .contains("window.location.pathname.match(/^\\/mindmap\\/([^\\/]+)\\/([^\\/]+)/)")); + assert!(SIDEBAR_TREE_JS.contains( + "var selected = activeRowId ? rowId === activeRowId : rowId === 'doc:' + activeId" + )); + assert!(SIDEBAR_TREE_JS + .contains("renderFileRows('', groupRowsByParent(rows), activeId, activeRowId)")); + assert!(SIDEBAR_TREE_JS.contains("renderFileRows(nodeId, grouped, activeId, activeRowId)")); + assert!(SIDEBAR_TREE_JS.contains("normalizeMindmapFileTreeTitle")); + assert!(SIDEBAR_TREE_JS.contains("shortMindmapFileName(assetId || title)")); + assert!( + SIDEBAR_TREE_JS.contains("var title = isFileTreeProjectionPageRow(rowKind, assetId)") + ); + assert!(!SIDEBAR_TREE_JS.contains("title: 'index.md'")); + assert!(!SIDEBAR_TREE_JS.contains("rowId === 'index:' + activeId")); + } + + #[test] + fn sidebar_tree_delete_to_trash_dispatches_archive_not_purge() { + assert!(SIDEBAR_TREE_JS.contains( + "if (action === 'delete-trash' && documentId) {\n if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;\n void dispatchTreeCommand(trigger || document.body, {\n action: 'archive'," + )); + assert!(!SIDEBAR_TREE_JS.contains( + "if (action === 'delete-trash' && documentId) {\n if (!window.confirm('确定要将“' + title + '”删除到垃圾桶吗?')) return;\n void dispatchTreeCommand(trigger || document.body, {\n action: 'purge'," + )); + } + + #[test] + fn sidebar_filetree_delete_keys_support_mixed_doc_and_asset_selection() { + assert!(SIDEBAR_TREE_JS.contains("function deleteSelectedSidebarFileTreeRows")); + assert!(SIDEBAR_TREE_JS.contains("event.key === 'Delete' || event.key === 'Backspace'")); + assert!(SIDEBAR_TREE_JS.contains( + "currentSourceKind() === 'local_folder' && fileTreeRowKind(row) === 'asset'" + )); + assert!(SIDEBAR_TREE_JS.contains("if (currentSourceKind() === 'local_folder')")); + assert!(SIDEBAR_TREE_JS.contains("documentId: fileAssetIds[lf]")); + assert!(SIDEBAR_TREE_JS.contains("/api/media/batch")); + assert!(SIDEBAR_TREE_JS.contains("/api/mindmap/")); + assert!(SIDEBAR_TREE_JS.contains("/api/tables/")); + assert!(SIDEBAR_TREE_JS.contains("确认删除选中的 ")); + } + #[test] fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() { assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType")); diff --git a/scripts/task-hermes-page-ai-queue-smoke.js b/scripts/task-hermes-page-ai-queue-smoke.js new file mode 100644 index 00000000..99f79e4e --- /dev/null +++ b/scripts/task-hermes-page-ai-queue-smoke.js @@ -0,0 +1,196 @@ +#!/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-queue-${suffix}`; + const sessionId = `mnote_queue_${suffix}`; + const runId = `run_queue_${suffix}`; + const queueId = `queue_${suffix}`; + const createdIds = []; + const runBodies = []; + const cancelBodies = []; + 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", 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" }] }), + }); + }); + await page.route("**/api/hermes/client/profile-memory**", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true, memory: "", user: "", soul: "" }), + }); + }); + 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: [], archived: [] }), + }); + }); + await page.route("**/api/hermes/client/tools**", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true, tools: [] }), + }); + }); + await page.route("**/api/hermes/client/sessions", async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true, sessionId, title: "队列测试", profile: "default" }), + }); + }); + await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + sessionId, + session: { sessionId, profile: "default", messages: [] }, + runtime: { sessionId, runId, status: "running", queueLength: 0 }, + }), + }); + }); + await page.route("**/api/hermes/client/runs", async (route) => { + runBodies.push(JSON.parse(route.request().postData() || "{}")); + if (runBodies.length === 1) { + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true, runId, sessionId, traceId: `trace_run_${suffix}` }), + }); + return; + } + await route.fulfill({ + status: 202, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ok: true, + queued: true, + queueId, + sessionId, + status: "queued", + queueLength: 1, + traceId: `trace_queue_${suffix}`, + }), + }); + }); + await page.route(`**/api/hermes/client/sessions/${sessionId}/queue/${queueId}`, async (route) => { + cancelBodies.push({ method: route.request().method(), url: route.request().url() }); + await route.fulfill({ + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true, sessionId, queueId, cancelled: true, queueLength: 0 }), + }); + }); + await page.route(`**/api/hermes/client/events/${runId}`, async () => { + // 保持第一个 run 处于 active 状态,让第二条输入进入队列。 + }); + + 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-input]").fill(`第一条 ${title}`, { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + await waitForCondition(() => runBodies.length === 1, UI_TIMEOUT_MS, "未捕获第一条 run 请求"); + + await page.locator("[data-page-ai-input]").fill(`第二条 ${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("已加入 Hermes 队列"), + null, + { timeout: UI_TIMEOUT_MS }, + ); + await page.waitForFunction( + () => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("队列 1"), + null, + { timeout: UI_TIMEOUT_MS }, + ); + await page.locator('.wolai-page-ai-icon[data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator('.wolai-page-ai-panel-section:not([hidden]) [data-page-ai-tab="runtime"]').click({ timeout: UI_TIMEOUT_MS }); + await page.locator(`[data-page-ai-queue-item="${queueId}"] [data-page-ai-action="cancel-queued-run"]`).click({ + timeout: UI_TIMEOUT_MS, + }); + await page.waitForFunction( + () => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("队列 1") === false, + null, + { timeout: UI_TIMEOUT_MS }, + ); + await waitForCondition(() => cancelBodies.length === 1, UI_TIMEOUT_MS, "未捕获取消 queued item 请求"); + + assert.equal(runBodies.length, 2, "第二条输入必须到达 mnote-web BFF"); + assert.equal(cancelBodies.length, 1, "取消 queued item 必须调用 mnote-web queue cancel API"); + assert.equal(cancelBodies[0].method, "DELETE", "取消 queued item 必须使用 DELETE"); + assert.equal(runBodies[0].sessionId, sessionId, "第一条 run 必须携带 Hermes sessionId"); + assert.equal(runBodies[1].sessionId, sessionId, "第二条 queued run 必须携带同一个 sessionId"); + + console.log( + JSON.stringify( + { + ok: true, + baseUrl: BASE_URL, + documentId: target.documentId, + workspaceId: target.workspaceId, + sessionId, + runId, + queueId, + runRequests: runBodies.length, + cancelRequests: cancelBodies.length, + }, + null, + 2, + ), + ); + } finally { + await cleanupDocuments(context.request, createdIds).catch(() => undefined); + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + } +} + +async function waitForCondition(predicate, timeoutMs, message) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(message); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task-hermes-page-ai-runtime-controls-smoke.js b/scripts/task-hermes-page-ai-runtime-controls-smoke.js index 623017a9..ee31cd7c 100644 --- a/scripts/task-hermes-page-ai-runtime-controls-smoke.js +++ b/scripts/task-hermes-page-ai-runtime-controls-smoke.js @@ -76,7 +76,7 @@ async function main() { }), }); }); - await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => { + await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, @@ -96,7 +96,9 @@ async function main() { await route.fulfill({ status: 200, headers: { "content-type": "text/event-stream; charset=utf-8" }, - body: `data: ${JSON.stringify({ event: "run.aborted", run_id: runId, session_id: sessionId })}\n\n`, + body: + `data: ${JSON.stringify({ event: "run.aborted", run_id: runId, session_id: sessionId })}\n\n` + + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "SHOULD_NOT_APPEAR_AFTER_ABORT" })}\n\n`, }); }); await page.route(`**/api/hermes/client/runs/${runId}/abort`, async (route) => { @@ -105,7 +107,16 @@ async function main() { await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true, runId, status: "aborted" }), + body: JSON.stringify({ + ok: true, + runId, + status: "aborted", + runtime: { sessionId, runId, status: "aborted" }, + events: [ + { event: "abort.started", runId }, + { event: "abort.completed", runId }, + ], + }), }); }); @@ -150,6 +161,14 @@ async function main() { null, { timeout: UI_TIMEOUT_MS }, ); + await page.waitForTimeout(100); + const drawerTextAfterAbort = await page.locator('[data-testid="wolai-page-ai-drawer"]').textContent({ + timeout: UI_TIMEOUT_MS, + }); + assert( + !drawerTextAfterAbort.includes("SHOULD_NOT_APPEAR_AFTER_ABORT"), + "abort 后不应继续追加该 run 的 assistant delta", + ); assert(sessionBodies.some((body) => body.profile === "default"), "创建 session 必须携带 profile"); assert(abortBodies.some((body) => body.reason === "page_ai_user_stop"), "停止 run 必须调用 Hermes abort API"); diff --git a/scripts/task-hermes-page-ai-smoke.js b/scripts/task-hermes-page-ai-smoke.js index 613fc328..17369a26 100644 --- a/scripts/task-hermes-page-ai-smoke.js +++ b/scripts/task-hermes-page-ai-smoke.js @@ -18,6 +18,7 @@ async function main() { const sessionId = `mnote_smoke_${suffix}`; const runId = `run_smoke_${suffix}`; let sessionDetailHits = 0; + let allowSessionRestore = false; const captured = []; const createdIds = []; const browser = await chromium.launch({ headless: true }); @@ -42,9 +43,10 @@ async function main() { }), }); }); - await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => { + await page.route(`**/api/hermes/client/sessions/${sessionId}/resume`, async (route) => { sessionDetailHits += 1; captured.push({ kind: "session-detail", method: route.request().method(), body: "" }); + assert.equal(route.request().method(), "POST", "刷新恢复必须调用 runtime resume 接口"); await route.fulfill({ status: 200, headers: { "content-type": "application/json" }, @@ -54,11 +56,22 @@ async function main() { traceId: "trace_restore", session: { sessionId, - messages: [ - { role: "user", content: `请总结 ${title}` }, - { role: "tool", content: "mnote.page.get" }, - { role: "assistant", content: "Smoke restored from Hermes session" }, - ], + messages: allowSessionRestore + ? [ + { role: "user", content: `请总结 ${title}` }, + { role: "tool", content: "mnote.page.get" }, + { role: "assistant", content: "Smoke restored from Hermes session" }, + ] + : [], + }, + runtime: { + sessionId, + runId, + status: "completed", + profile: "default", + documentId: "doc_smoke", + traceId: "trace_restore", + lastToolName: "mnote.page.get", }, }), }); @@ -83,8 +96,10 @@ async function main() { status: 200, headers: { "content-type": "text/event-stream; charset=utf-8" }, body: - `data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` + - `data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` + + `data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", args: { includeBody: false } })}\n\n` + + `data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_get", name: "mnote.page.get", summary: "读取当前页面", auditId: "audit_smoke_page_get" })}\n\n` + + `data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", args: { dryRun: true } })}\n\n` + + `data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, toolCallId: "call_smoke_page_save", name: "mnote.page.save", code: "permission_denied", error: "写入被拒绝", auditId: "audit_smoke_page_save" })}\n\n` + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Smoke " })}\n\n` + `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "response" })}\n\n` + `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Smoke response" })}\n\n`, @@ -118,6 +133,22 @@ async function main() { null, { timeout: UI_TIMEOUT_MS }, ); + const toolCards = await page.$$eval("[data-page-ai-tool-card]", (cards) => + cards.map((card) => ({ + id: card.getAttribute("data-page-ai-tool-call-id"), + status: card.getAttribute("data-page-ai-tool-status"), + text: card.textContent || "", + })), + ); + assert.equal(toolCards.length, 2, `应渲染 2 张工具卡:${JSON.stringify(toolCards)}`); + assert( + toolCards.some((card) => card.status === "completed" && card.text.includes("call_smoke_page_get")), + `缺少 completed 工具卡:${JSON.stringify(toolCards)}`, + ); + assert( + toolCards.some((card) => card.status === "failed" && card.text.includes("permission_denied")), + `缺少 failed 工具卡:${JSON.stringify(toolCards)}`, + ); const sessionRequest = captured.find((entry) => entry.kind === "session"); const runRequest = captured.find((entry) => entry.kind === "run"); @@ -130,6 +161,7 @@ async function main() { assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId"); assert(runBody.pageContext, "run 请求必须携带 pageContext"); + allowSessionRestore = true; await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); await page.waitForFunction(