From 922965d30f0b63569fba932bb069cb6671905dd3 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 9 Jun 2026 18:48:46 +0800 Subject: [PATCH] feat(page-ai): add resumable run journal descriptors --- ...rence-ai-run-config-knowledge-facade-v1.md | 466 +++++ rust/crates/control-plane/src/model.rs | 6 + rust/crates/control-plane/src/sqlite.rs | 120 +- rust/crates/control-plane/src/store.rs | 32 +- .../sidebar-page-ai-profile-runtime.js | 2 +- .../browser/sidebar-page-ai-render-runtime.js | 79 +- .../browser/sidebar-page-ai-runtime.js | 374 +++- .../sidebar-page-ai-session-runtime.js | 34 +- .../mnote-web/src/routes/hermes_client.rs | 1512 ++++++++++++++++- rust/crates/mnote-web/src/routes/mod.rs | 17 + rust/crates/mnote-web/src/ssr/pages/layout.rs | 21 + scripts/reasonix-acp-wrapper.mjs | 128 +- ...owledge-rag-citation-resource-tab-smoke.js | 43 + ...nowledge-rag-page-ai-final-answer-smoke.js | 75 +- ...38-knowledge-rag-source-scope-api-smoke.js | 14 + .../task557-page-ai-agent-descriptor-smoke.js | 168 ++ scripts/task557-page-ai-run-resume-smoke.js | 221 +++ 17 files changed, 3254 insertions(+), 58 deletions(-) create mode 100644 design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md create mode 100644 scripts/task557-page-ai-agent-descriptor-smoke.js create mode 100644 scripts/task557-page-ai-run-resume-smoke.js diff --git a/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md b/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md new file mode 100644 index 00000000..f8e40de0 --- /dev/null +++ b/design/07-ai/done/7-57-yuxi-reference-ai-run-config-knowledge-facade-v1.md @@ -0,0 +1,466 @@ +# 7-57 [done] Yuxi reference: AI run journal, config schema and knowledge facade v1 + +> 创建时间:2026-06-09 +> +> 当前状态:`DONE` +> +> Owner:07-ai / Page AI runtime / Hermes client runs / Reasonix ACP / Knowledge RAG facade +> +> 参考代码: +> - `reference-code/Yuxi/backend/package/yuxi/services/run_queue_service.py` +> - `reference-code/Yuxi/backend/package/yuxi/services/agent_run_service.py` +> - `reference-code/Yuxi/web/src/composables/useAgentRunStream.js` +> - `reference-code/Yuxi/backend/package/yuxi/agents/context.py` +> - `reference-code/Yuxi/backend/package/yuxi/agents/toolkits/kbs/tools.py` +> +> 上位依据: +> - `ARCHITECTURE.md` +> - `CURRENT_ARCHITECTURE.md` +> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` +> - `design/07-ai/done/7-39-page-ai-agent-selector-context-authorization-settings-v1.md` +> - `design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md` +> - `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` +> - `design/07-ai/done/7-51-lightrag-post-commit-hardening-v1.md` + +## 1. 第一结论 + +Yuxi 值得参考的是三类工程模型,不是整体架构: + +1. **AI run event journal**:把一次 agent run 变成可查询、可恢复、可断线重连的事件流,而不是只依赖当前浏览器连接上的一次 SSE。 +2. **配置 schema 驱动 UI**:用后端声明 agent 可配置字段、权限、默认值和资源选项,前端按 schema 渲染,减少 Hermes / Reasonix / Chat-only 设置漂移。 +3. **知识库工具 facade**:agent 只通过稳定工具面访问知识库,query 返回引用,open_reference 负责把 provider reference 转成 MNote 可点击定位。 + +这些模型适合 MNote 当前 Page AI / ACP / LightRAG 主线,但必须受 MNote 边界约束: + +- 不复制 Yuxi 的 LangGraph agent runtime。 +- 不引入 Yuxi 的 Milvus / Neo4j / Postgres 知识库主存储。 +- 不把 Vue/Ant Design 前端模式迁入 Rust SSR / browser runtime。 +- 不改变 local-first 普通 Markdown 编辑主路径:仍是授权文件引用 + agent 原生 patch/diff + watcher/BufferStore/Page Aggregate 同步。 +- 不恢复旧 LiteParse / evidence / local OCR 默认路径;知识库问答继续以 LightRAG 为唯一默认 provider。 + +## 2. Yuxi 对照结论 + +### 2.1 AI run event journal + +Yuxi 的 run 链路是: + +```text +POST /api/agent/runs + -> 创建 AgentRun 行和 input message + -> ARQ worker 执行 + -> Redis stream append event + -> GET /api/agent/runs/{runId}/events + -> 前端用 Last-Event-ID / after_seq 恢复 +``` + +可借鉴点: + +- 事件流有 `seq` cursor,浏览器刷新后可以从最后 seq 继续。 +- run 有 terminal reconciliation:SSE 断开时查 run 状态,必要时重连或补 terminal event。 +- active run snapshot 放在浏览器侧,服务端 `active_run` 是权威判定。 +- interrupted / resume run 用 `parent_run_id + resume_request_id` 做幂等。 + +不适合直接搬: + +- MNote 不把 Hermes / Reasonix 包进 LangGraph worker。 +- MNote 不要求所有 agent run 进入一个 Python queue;第一阶段只给现有 `/api/hermes/client/runs`、Reasonix ACP bridge、Chat-only provider 增加宿主侧 run journal。 +- MNote 不用 Redis 作为默认 local-first 控制面;优先 SQLite control-plane,长连接仍由当前 Rust Web 承载。 + +### 2.2 配置 schema 驱动 UI + +Yuxi 的 `BaseContext` 用 dataclass field metadata 声明 `model/tools/knowledges/mcps/skills` 等配置,并按角色过滤。 + +可借鉴点: + +- agent 后端自己声明配置项,前端不硬编码每个 agent 的全部字段。 +- 字段带 `kind`,例如 `llm/tools/knowledges/mcps/skills`,可由后端补资源 options。 +- admin-only 字段由服务端过滤,不能只靠前端隐藏。 + +不适合直接搬: + +- MNote 当前已有 `PAGE_AI_AGENT_REGISTRY`、SQLite `user_ui_preferences`、directory grants、Hermes tool manifest 和 Reasonix wrapper selftest。 +- 因此不新增一套 Python 式 context schema;应把现有 Rust manifest / control-plane preference / agent registry 收成一个统一的 `mnote.ai_agent_descriptor.v1`。 + +### 2.3 知识库工具 facade + +Yuxi 的知识库工具强调: + +- `list_kbs` 列出当前 runtime 可访问知识库。 +- `query_kb` 按 `kb_id + query_text` 检索。 +- `open_kb_document` 用 `kb_id + file_id + line/window` 打开更完整上下文。 +- 工具先做 visible scope 校验,再查 retriever。 + +可借鉴点: + +- query 只返回检索片段和可追踪引用,不暴露 provider 内部目录作为最终引用。 +- open 是单独工具,避免 query 结果过大。 +- runtime visible scope 是工具入口的第一层校验。 + +MNote 当前已有更接近目标的实现: + +- `mnote.knowledge_rag.status` +- `mnote.knowledge_rag.query` +- `mnote.knowledge_rag.open_reference` +- `/api/knowledge-rag/search` +- `sourceScopeMode=post_filter_mapped_references` +- `citationMarkdown / citationUrl / locatorDegraded` + +因此 7-57 不重做知识库工具,而是补 facade 合同和漂移保护。 + +## 3. 目标架构 + +```text +Page AI sidebar + -> create host AI run + -> freeze target/contextRefs/allowedRoots/capability snapshot + -> dispatch to Hermes / Reasonix / Chat-only + -> bridge provider events into AI run event journal + -> browser consumes resumable run events + -> tool calls use schema-driven capability manifest + -> knowledge-rag query/open_reference returns MNote citations +``` + +三条线要共享同一个 run identity: + +- `hostRunId`:MNote 宿主侧 run id,Page AI UI、audit、event journal 和 browser resume 使用。 +- `providerRunId`:Hermes / Reasonix / Chat-only 自己的 run/session id,作为外部关联字段。 +- `requestId`:用户一次点击发送生成的幂等键,浏览器重试时不重复创建 run。 + +## 4. Design A:AI run event journal + +### 4.1 数据合同 + +第一阶段优先落 SQLite control-plane,避免引入 Redis。 + +```json +{ + "schema": "mnote.ai_run.v1", + "hostRunId": "run_...", + "requestId": "client_req_...", + "agentId": "reasonix", + "provider": "acp_reasonix", + "providerRunId": "external-run-id", + "sessionId": "session-id", + "actorId": "user-id", + "workspaceId": "local:workspace", + "status": "pending|running|interrupted|completed|failed|cancelled", + "targetSnapshotHash": "sha256...", + "capabilitySnapshotHash": "sha256...", + "createdAt": "2026-06-09T00:00:00Z", + "updatedAt": "2026-06-09T00:00:01Z" +} +``` + +```json +{ + "schema": "mnote.ai_run_event.v1", + "hostRunId": "run_...", + "seq": "000000000000000001", + "kind": "message.delta|tool.started|tool.completed|plan.updated|session.info.updated|changed_files|end|error", + "source": "mnote|hermes|reasonix|chat_only|tool", + "payload": {}, + "createdAt": "2026-06-09T00:00:01Z" +} +``` + +`seq` 第一阶段可用单调递增整数文本,不需要复刻 Redis stream id。要求只满足: + +- 同一 `hostRunId` 内严格递增。 +- 客户端传 `afterSeq` 时只返回更大的事件。 +- terminal event 必须可补发,防止浏览器漏掉结束状态。 + +### 4.2 API 合同 + +第一阶段不替换现有 `/api/hermes/client/runs`,而是在其外层增加宿主 run receipt: + +```text +POST /api/page-ai/runs +GET /api/page-ai/runs/{hostRunId} +GET /api/page-ai/runs/{hostRunId}/events?afterSeq=... +POST /api/page-ai/runs/{hostRunId}/cancel +GET /api/page-ai/sessions/{sessionId}/active-run +``` + +兼容策略: + +- 旧 `/api/hermes/client/runs` 可继续存在,但 Page AI runtime 默认走新 receipt route。 +- 新 route 内部仍可调用 Hermes client proxy / Reasonix ACP bridge。 +- 若 provider 已经有自己的 SSE,Rust Web 只做事件桥接和 journal append,不接管 provider agent loop。 + +### 4.3 前端恢复规则 + +浏览器保存轻量 snapshot: + +```json +{ + "hostRunId": "run_...", + "sessionId": "session-id", + "lastSeq": "000000000000000031", + "createdAt": 1780000000000 +} +``` + +恢复流程: + +1. 页面可见或 Page AI 打开时,查本地 snapshot。 +2. 调 `active-run` 做权威判定。 +3. 如果服务端 run 仍是 `pending/running/interrupted`,用 `afterSeq=lastSeq` 继续消费。 +4. 如果服务端 run 已 terminal,拉取缺失事件并清掉 snapshot。 +5. 如果本地 snapshot 对应的 run 不存在,清掉本地状态,不能伪装仍在运行。 + +### 4.4 验收 + +- 刷新浏览器后,正在运行的 Reasonix / Hermes run 不丢消息。 +- SSE 断开后,前端能基于 `afterSeq` 续接,不重复渲染旧 tool event。 +- terminal event 漏掉时,状态查询能补齐 `completed/failed/cancelled`。 +- 同一 `requestId` 重试不会创建两个 host run。 +- `changed_files`、tool citation、plan/session info 都进入 journal,但不进入页面正文真相。 + +## 5. Design B:AI agent descriptor schema + +### 5.1 目标 + +把 7-39 / 7-40 已经落地的 registry、preference、tool manifest 收成一个后端可查询合同: + +```json +{ + "schema": "mnote.ai_agent_descriptor.v1", + "agentId": "reasonix", + "displayName": "Reasonix", + "provider": "acp_reasonix", + "capabilities": ["file_read", "file_write", "knowledge_rag", "native_patch"], + "defaultContextRefs": ["current_page", "active_editor"], + "settingScopes": ["ai.common", "ai.agent.reasonix"], + "fields": [ + { + "key": "ai.agent.reasonix.profile_id", + "label": "Profile", + "kind": "select", + "default": "default", + "optionsSource": "reasonix_profiles", + "auth": "user", + "secret": false + } + ], + "tools": [ + { + "name": "mnote.knowledge_rag.query", + "capabilityIds": ["knowledge_rag"], + "enabled": true + } + ] +} +``` + +### 5.2 来源合并顺序 + +```text +Rust static agent registry + + Hermes tool manifest + + Reasonix wrapper mapping/selftest + + SQLite user_ui_preferences + + SQLite directory_grants derived allowedRoots + -> ai_agent_descriptor response +``` + +规则: + +- `directory_grants` 不进入普通 field;它是权限事实,只能作为 allowed roots 派生。 +- secret / token 类字段不得通过 descriptor 返回明文。 +- agent-specific 设置只写入 `ai.agent.` scope。 +- common 设置只写入 `ai.common` scope,例如默认 agent、默认 contextRefs、citation 显示策略。 +- 前端可以缓存 descriptor,但发送 run 前服务端必须重新解析权限和 enabled tools。 + +### 5.3 UI 结果 + +前端不再维护多份工具表: + +- Agent selector 从 descriptor 渲染。 +- 设置页 tabs 从 descriptor 渲染。 +- Knowledge RAG 能力是否可用从 descriptor 的 `tools/capabilities` 渲染。 +- Chat-only 默认不暴露文件写入和 MNote context tools。 +- Hermes / Reasonix wrapper 漂移由 descriptor selftest 捕获。 + +### 5.4 验收 + +- `GET /api/page-ai/agents/descriptors` 返回 Hermes / Reasonix / Chat-only 三类 descriptor。 +- Alice/Bob 的 common preference 隔离。 +- `mnote.knowledge_rag.*` manifest 与 Reasonix wrapper 双向一致。 +- 禁用 Knowledge RAG 后,descriptor 中 capability disabled,Page AI 不引导 agent 调用该工具。 +- 前端删除硬编码工具 enable 列表,只保留渲染逻辑和兼容 fallback。 + +## 6. Design C:Knowledge RAG facade hardening + +### 6.1 工具面 + +维持当前主工具,不新增第二套 provider: + +```text +mnote.knowledge_rag.status +mnote.knowledge_rag.query +mnote.knowledge_rag.open_reference +``` + +建议补一个 facade-level `search` 工具时再单独拆小设计;当前已有 `/api/knowledge-rag/search`,但未必需要默认暴露给 agent。 + +### 6.2 query 输出要求 + +`mnote.knowledge_rag.query` 必须稳定返回: + +```json +{ + "schema": "mnote.knowledge_rag.query_result.v1", + "answer": "...", + "sourceScopeMode": "post_filter_mapped_references", + "references": [ + { + "schema": "mnote.knowledge_rag.reference.v1", + "sourcePath": "docs/a.pdf", + "filePath": "a.pdf", + "citationMarkdown": "[a.pdf · p.3](...)", + "citationUrl": "...", + "locatorDegraded": false + } + ], + "citations": [] +} +``` + +要求: + +- agent 最终回答优先用 `citationMarkdown`。 +- `locatorDegraded=true` 时只能说“来源定位降级”,不能编页码 / bbox。 +- `sourcePaths` 的语义继续是 `post_filter_mapped_references`,不得描述成 provider 原生检索 scope。 +- compact tool output 不暴露 provider raw chunks,除非明确进入 debug。 + +### 6.3 open_reference 输出要求 + +`open_reference` 是唯一把 provider reference 转成 MNote UI locator 的工具: + +- 输入可以是 query 返回的 reference,也可以是 `file_path/chunk_id` 这类 provider hint。 +- 输出必须包含 `citationUrl/citationMarkdown`。 +- 能定位 page/bbox/block 时返回精确 locator。 +- 不能定位时返回 degraded resourceTab citation。 +- 不返回 `file://`、LightRAG dashboard URL 或 `.mnote/index` 私有路径给 agent 作为最终引用。 + +### 6.4 与 Yuxi 的差异 + +Yuxi 的 `open_kb_document(kb_id,file_id,line/window)` 适合普通文本知识库;MNote 的资料库来源包含 PDF、DOCX、图片、resource tab 和 bbox,因此不能把 `file_id + line window` 作为唯一打开模型。 + +MNote 可以参考它的“两阶段工具”: + +```text +query -> reference id / source hint +open_reference -> MNote locator / clickable citation +``` + +但不要退回“按行打开一切”的模型。 + +### 6.5 验收 + +- `task529` 继续覆盖 open_reference 能打开 resource tab。 +- `task530` 继续覆盖 Page AI final answer 不泄漏 `citationMarkdown` 字段名、不编造 locator。 +- `task538` 继续覆盖 `sourceScopeMode=post_filter_mapped_references`。 +- Reasonix wrapper selftest 覆盖 `mnote.knowledge_rag.status/query/open_reference`。 +- 新增一个 descriptor smoke:禁用 Knowledge RAG 时 Page AI 不显示知识库能力,启用时工具表与 manifest 一致。 + +## 7. 实施拆分 + +### Batch A:只读对齐与 descriptor 草案 + +- [x] 读取当前 `PAGE_AI_AGENT_REGISTRY`、Hermes tool manifest、Reasonix wrapper 映射、SQLite AI preferences。 +- [x] 输出 `mnote.ai_agent_descriptor.v1` 的 Rust 结构和只读 route。 +- [x] 不改 run 创建链路。 +- [x] 验证:Rust 单测 + 一个 JS smoke 断言三类 agent descriptor。 + +执行记录(2026-06-09): + +- 已新增 `GET /api/page-ai/agents/descriptors`,返回 `mnote.ai_agent_descriptors.v1`,包含 Hermes / Reasonix / Chat-only 三类 descriptor、当前 actor 的 `ai.common.*` / `ai.agent.*` preference values、manifest tools 和 capability packs。 +- Chat-only descriptor 不暴露 MNote tools,`canWriteFiles=false`;Hermes / Reasonix descriptor 继续从 Hermes manifest 和 MNote capability packs 暴露 `mnote.knowledge_rag.*` 等工具状态。 +- 已补 Rust route 单测 `page_ai_agent_descriptors_merge_manifest_capabilities_and_preferences`,覆盖三类 agent、Reasonix Knowledge RAG tool、Chat-only 无工具、Alice/Bob preference 隔离。 +- 已补 JS smoke `scripts/task557-page-ai-agent-descriptor-smoke.js`,覆盖真实 `3000` API 返回的三类 agent descriptor、Reasonix `mnote.knowledge_rag.status/query/open_reference`、Chat-only 无 MNote tools 与空默认 context refs。 +- 已验证:`cd rust && cargo test -p mnote-web page_ai_agent_descriptors --lib`、`cargo test -p mnote-web page_ai_capabilities_expose_knowledge_rag_and_toggle_tools --lib`、`cargo test -p mnote-web hermes_client_tools_uses_manifest_and_profile_disabled_state --lib`。 +- 已验证:`node scripts/task557-page-ai-agent-descriptor-smoke.js`。 + +前端继续执行记录(2026-06-09): + +- Page AI runtime 已新增 descriptor 加载链路:打开 drawer 时请求 `/api/page-ai/agents/descriptors?profile=...`,成功后用后端返回的 Hermes / Reasonix / Chat-only descriptor 覆盖本地 fallback registry。 +- Agent picker、当前 agent chip、history agent label 和 mnote tool list 已优先消费 descriptor 的 `displayName`、`acpRuntime`、`canWriteFiles`、`capabilities`、`tools`。 +- Agent / Hermes / Reasonix / Chat-only 设置页已按 `descriptor.fields` 渲染最小 schema UI,当前支持 `select`、`string`、`boolean/toggle`,并通过 `ai.*` preference key 写回 `/api/ui/preferences`。 +- `PAGE_AI_AGENT_FALLBACK_REGISTRY` 仅作为 descriptor API 失败时的加载失败 fallback,不再作为正常路径的唯一 agent 字段来源。 +- 已补 SSR runtime 合同测试,确保前端 bundle 保留 descriptor endpoint、descriptor field 控件和 agent descriptor card。 + +### Batch B:host run journal 最小闭环 + +- [x] 新增 SQLite run / run_event 存储或复用现有 run 表,明确 migration。 +- [x] 为 Page AI run 创建 `hostRunId/requestId/status`。 +- [x] 将现有 Hermes / Reasonix SSE 事件 append 到 journal。 +- [x] 新增 `GET /events?afterSeq=`。 +- [x] 验证:单测覆盖 seq 去重、terminal 补发、requestId 幂等。 + +执行记录(2026-06-09): + +- 已复用 `control-plane` 现有 `ai_runtime_runs` / `ai_runtime_events`(migration `002-ai-runtime-store.sql`),补 `find_ai_runtime_run` 与 `list_ai_runtime_journal_events(after_seq, limit)`;journal seq 由同一 run 内事件顺序生成并格式化为 18 位文本 cursor。 +- 已新增 `GET /api/page-ai/runs/{hostRunId}` 和 `GET /api/page-ai/runs/{hostRunId}/events?afterSeq=...`;当前第一步兼容现有 `run_id`,`hostRunId` 暂时 alias 现有 persisted run id,后续 `POST /api/page-ai/runs` 再拆真正 host/provider 双 id。 +- 已新增 `POST /api/page-ai/runs` 创建宿主侧 receipt:写入 `hostRunId/requestId/status=pending`,并追加 `run.created` 初始事件;同一 `requestId + sessionId + workspaceId + documentId` 重试返回既有 run,不重复追加事件。 +- `GET /api/page-ai/runs/{hostRunId}/events?afterSeq=...` 已在 terminal status 存在但 terminal event 缺失时补一个 synthetic terminal event,用于浏览器刷新或 SSE 断线后的终态 reconciliation。 +- 已补 Rust route 单测 `page_ai_run_events_expose_journal_after_seq`,覆盖 run receipt 查询、`afterSeq=000000000000000001` 只返回 seq 2、event schema/kind/source/payload。 +- 已补 Rust route 单测 `page_ai_run_create_is_request_id_idempotent`,覆盖 `requestId` 幂等创建、重复 POST 不重复追加 `run.created`。 +- 已补 Rust route 单测 `page_ai_run_events_reconcile_missing_terminal_event`,覆盖缺 terminal event 时按 run status 补发 synthetic terminal event。 +- 已扩展 `api_chat_events_stream_from_openai_compatible_upstream`,验证 provider SSE 消费后可通过 `/api/page-ai/runs/{runId}/events?afterSeq=0` 读到 journal 中的 `message.delta` 与 `run.completed`。 +- 已把 persisted journal `seq` 回填到 API Chat / ACP SSE payload,前端实时消费时可同步推进 active-run snapshot cursor;ACP/Reasonix 事件仍由既有 `persist_acp_runtime_event` 写入 SQLite journal。 +- 已验证:`cargo test -p control-plane ai_runtime_run_upsert_lists_updates_and_events --lib`、`cargo test -p mnote-web page_ai_run_ --lib`。 + +### Batch C:前端 resume + +- [x] Page AI runtime 保存 `{hostRunId, sessionId, lastSeq}`。 +- [x] 页面可见 / 打开 sidebar 时执行 active-run 判定。 +- [x] 恢复 SSE 时用 `afterSeq`,避免重复 tool card。 +- [x] 验证:浏览器 smoke 模拟刷新后继续接收事件。 + +执行记录(2026-06-09): + +- 已新增 `GET /api/page-ai/sessions/{sessionId}/active-run`,按 `workspaceId/documentId/sessionId` 返回当前非 terminal host run,响应 schema 为 `mnote.ai_active_run.v1`。 +- Page AI session runtime 已新增 `pageAiCheckActiveRun()`;打开 drawer 完成 session 加载后调用一次,页面重新 visible 且 drawer 打开时再次调用。命中 active run 后会更新当前 session `runId/status`、`data-mnote-page-ai-active-host-run-id` 和 run status UI。 +- Page AI runtime 已新增 `mnote.page_ai_active_run_snapshot.v1` localStorage snapshot;实时 SSE payload 含 `seq` 时同步写入 `lastSeq`,terminal status 清理 snapshot。 +- 打开 drawer / 页面重新 visible 命中 active run 后,会继续调用 `/api/page-ai/runs/{hostRunId}/events?afterSeq={lastSeq}` 恢复 journal,并把 `message.delta` / `tool.*` / `plan.updated` / terminal event 重新投递到当前 conversation。 +- 已补 Rust route 单测 `page_ai_session_active_run_returns_non_terminal_run`,覆盖同一 session 下 completed run 被忽略、running run 被选为 active。 +- 已补 SSR runtime 合同测试,确保前端 bundle 保留 active-run endpoint、`pageAiCheckActiveRun()`、`pageAiResumeActiveRunJournal()`、`afterSeq` cursor、snapshot schema 和 `visibilitychange` 判定。 +- 已补 `scripts/task557-page-ai-run-resume-smoke.js`:种入 running host run 与两条 journal event,浏览器 localStorage 保留 `lastSeq=000000000000000001` 后打开页面 AI,验证前端调用 `active-run` 与 `/events?afterSeq=...`,只恢复 `resumed-token`,不重复渲染 `old-token`。 +- Page AI 打开 drawer 后会优先按 active-run snapshot 的 `sessionId` 恢复;后端会话列表加载完成后再次判定,避免 profile/settings 等慢请求阻塞 journal resume。 +- 已验证:`cargo test -p mnote-web page_ai_session_active_run_returns_non_terminal_run --lib`、`cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store --lib`、`cargo test -p mnote-web api_chat_events_stream_from_openai_compatible_upstream --lib`。 +- 已验证:`node scripts/task557-page-ai-run-resume-smoke.js`。 + +### Batch D:Knowledge facade 漂移保护 + +- [x] descriptor 暴露 Knowledge RAG capability enabled/disabled。 +- [x] selftest 同时校验 Rust manifest、Reasonix wrapper、skill 文案中的工具名。 +- [x] 若 query 输出缺 `citationMarkdown`,Page AI final answer smoke 必须失败。 +- [x] 验证:`task529`、`task530`、`task538` 加 descriptor 断言。 + +执行记录(2026-06-09): + +- Descriptor 已新增 `capabilityStates` / `disabledCapabilities`;禁用 `mnote-knowledge-rag` 后,Reasonix descriptor 不再在 `capabilities` 中声明 `knowledge_rag`,对应 `mnote.knowledge_rag.*` tool 标记为 disabled。 +- 已扩展 Rust 单测 `page_ai_agent_descriptors_merge_manifest_capabilities_and_preferences`,覆盖 Knowledge RAG enabled/disabled descriptor 状态与 disabled tool policy。 +- 已扩展 `scripts/task557-page-ai-agent-descriptor-smoke.js`,真实 3000 API smoke 覆盖默认启用与禁用后的 descriptor/tool 状态。 +- 已扩展 `scripts/reasonix-acp-wrapper.mjs` selftest:同时校验 Rust manifest、Reasonix wrapper mapping、`skills/mnote-knowledge-rag/SKILL.md` 文案中的 `mnote.knowledge_rag.status/query/open_reference` 工具名一致。 +- 已给 `task529` / `task530` / `task538` 补 descriptor 断言;`task530` 现在会先直接调用 `knowledge-rag/query` 并要求 reference 含 `citationMarkdown`,否则 Page AI final answer smoke 直接失败。 +- 已把 `task529` / `task530` 的目标图片资料改为先 ingest + 等待 indexed,避免 smoke 依赖现场 registry 预置状态。 +- 已给 LightRAG provider HTTP 5xx `RetryError` / `InvalidResponseError` / empty content 增加一次短 retry,避免 Reasonix Page AI 路径因 provider 偶发空响应直接失败。 +- 已验证:`node scripts/task529-knowledge-rag-citation-resource-tab-smoke.js`、`node scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js`、`node scripts/task538-knowledge-rag-source-scope-api-smoke.js`、`node scripts/task557-page-ai-agent-descriptor-smoke.js`、`MNOTE_REASONIX_ACP_SELFTEST=1 node scripts/reasonix-acp-wrapper.mjs`。 + +## 8. 非目标 + +- 不把 Yuxi 拉入运行依赖。 +- 不迁移到 FastAPI / Vue / Milvus / Neo4j。 +- 不把 AI run journal 当作聊天全文主存储;provider 自己的历史仍由 provider 管。 +- 不让 run journal 写页面正文或 Page Aggregate。 +- 不改变 LightRAG provider 的 source truth、delete/reindex 语义。 +- 不新增定时轮询刷新 Page AI;恢复靠显式打开、页面可见、SSE reconnect 和 terminal reconciliation。 + +## 9. 风险 + +- 如果 journal 记录过多 token delta,SQLite 可能膨胀。第一版应 compact tool/result payload,保留 UI 恢复必需事件,不保存大段 provider raw chunks。 +- 如果 descriptor 同时由前端和后端维护,会形成新漂移。必须以后端 route 为准,前端只渲染。 +- 如果 `hostRunId` 与 provider run id 混用,审计会断链。所有 UI、audit、changed files 都使用 hostRunId,provider id 只作为关联字段。 +- 如果把 Yuxi 的 `line/window open` 直接套到 PDF/DOCX/image,会破坏 MNote locator 模型。Knowledge facade 必须继续以 `open_reference -> citationUrl` 为中心。 diff --git a/rust/crates/control-plane/src/model.rs b/rust/crates/control-plane/src/model.rs index e1ad8910..b08c8b73 100644 --- a/rust/crates/control-plane/src/model.rs +++ b/rust/crates/control-plane/src/model.rs @@ -414,6 +414,12 @@ pub struct AiRuntimeEventRecord { pub created_at: Timestamp, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiRuntimeJournalEventRecord { + pub seq: i64, + pub event: AiRuntimeEventRecord, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AppendAiRuntimeEventInput { pub id: Option, diff --git a/rust/crates/control-plane/src/sqlite.rs b/rust/crates/control-plane/src/sqlite.rs index 4775c86d..d598a47c 100644 --- a/rust/crates/control-plane/src/sqlite.rs +++ b/rust/crates/control-plane/src/sqlite.rs @@ -10,15 +10,15 @@ use crate::migrations; use crate::model::{ password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord, AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord, - AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, - AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, - CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, - DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, - OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, - SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, - UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, - UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord, - UserUiPreferenceRecord, WorkspaceRecord, + AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, + AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, + AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, + CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, + DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, + ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, + UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, + UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, + UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord, }; use crate::store::ControlPlaneStore; @@ -424,6 +424,27 @@ fn row_to_ai_runtime_event(row: &rusqlite::Row<'_>) -> rusqlite::Result, +) -> rusqlite::Result { + Ok(AiRuntimeJournalEventRecord { + seq: row.get(0)?, + event: AiRuntimeEventRecord { + id: row.get(1)?, + user_id: row.get(2)?, + workspace_id: row.get(3)?, + document_id: row.get(4)?, + session_id: row.get(5)?, + run_id: row.get(6)?, + profile: row.get(7)?, + acp_runtime: row.get(8)?, + event_type: row.get(9)?, + payload_json: row.get(10)?, + created_at: row.get(11)?, + }, + }) +} + fn row_to_ai_external_conversation_binding( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { @@ -2355,6 +2376,24 @@ impl ControlPlaneStore for SqliteControlPlaneStore { Ok(rows) } + fn find_ai_runtime_run( + &self, + user_id: &str, + run_id: &str, + ) -> Result, ControlPlaneError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision + FROM ai_runtime_runs + WHERE user_id = ?1 AND run_id = ?2 AND deleted_at IS NULL + LIMIT 1", + params![user_id, run_id], + row_to_ai_runtime_run, + ) + .optional() + .map_err(ControlPlaneError::from) + } + fn append_ai_runtime_event( &self, input: AppendAiRuntimeEventInput, @@ -2427,6 +2466,40 @@ impl ControlPlaneStore for SqliteControlPlaneStore { Ok(rows) } + fn list_ai_runtime_journal_events( + &self, + user_id: &str, + run_id: &str, + after_seq: i64, + limit: usize, + ) -> Result, ControlPlaneError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "WITH ordered AS ( + SELECT + ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS seq, + id, user_id, workspace_id, document_id, session_id, run_id, profile, + acp_runtime, event_type, payload_json, created_at + FROM ai_runtime_events + WHERE user_id = ?1 AND run_id = ?2 + ) + SELECT seq, id, user_id, workspace_id, document_id, session_id, run_id, profile, + acp_runtime, event_type, payload_json, created_at + FROM ordered + WHERE seq > ?3 + ORDER BY seq ASC + LIMIT ?4", + )?; + let rows = stmt + .query_map( + params![user_id, run_id, after_seq.max(0), limit.max(1) as i64], + row_to_ai_runtime_journal_event, + )? + .collect::, _>>() + .map_err(ControlPlaneError::from)?; + Ok(rows) + } + fn upsert_ai_external_conversation_binding( &self, input: UpsertAiExternalConversationBindingInput, @@ -3666,12 +3739,39 @@ mod tests { }) .expect("append runtime event"); assert_eq!(event.event_type, "message.delta"); + store + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id: "ai_runtime_user".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + event_type: "run.completed".to_string(), + payload_json: "{\"status\":\"completed\"}".to_string(), + }) + .expect("append second runtime event"); let events = store .list_ai_runtime_events("ai_runtime_user", "run_1", 10) .expect("list runtime events"); - assert_eq!(events.len(), 1); + assert_eq!(events.len(), 2); assert_eq!(events[0].payload_json, "{\"text\":\"hello\"}"); + + let found = store + .find_ai_runtime_run("ai_runtime_user", "run_1") + .expect("find runtime run") + .expect("runtime run exists"); + assert_eq!(found.status, "completed"); + + let journal_events = store + .list_ai_runtime_journal_events("ai_runtime_user", "run_1", 1, 10) + .expect("list runtime journal events"); + assert_eq!(journal_events.len(), 1); + assert_eq!(journal_events[0].seq, 2); + assert_eq!(journal_events[0].event.event_type, "run.completed"); } #[test] diff --git a/rust/crates/control-plane/src/store.rs b/rust/crates/control-plane/src/store.rs index 5091dfd1..43e8a156 100644 --- a/rust/crates/control-plane/src/store.rs +++ b/rust/crates/control-plane/src/store.rs @@ -3,15 +3,15 @@ use crate::error::ControlPlaneError; use crate::model::{ AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiPolicyRecord, - AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, - AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, - CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, - DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, - OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, - SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, - UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput, - UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord, - UserUiPreferenceRecord, WorkspaceRecord, + AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, + AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, + AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, + CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, + DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, + ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, + UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, + UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, + UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord, }; pub trait ControlPlaneStore: Send + Sync { @@ -208,6 +208,12 @@ pub trait ControlPlaneStore: Send + Sync { limit: usize, ) -> Result, ControlPlaneError>; + fn find_ai_runtime_run( + &self, + user_id: &str, + run_id: &str, + ) -> Result, ControlPlaneError>; + fn append_ai_runtime_event( &self, input: AppendAiRuntimeEventInput, @@ -220,6 +226,14 @@ pub trait ControlPlaneStore: Send + Sync { limit: usize, ) -> Result, ControlPlaneError>; + fn list_ai_runtime_journal_events( + &self, + user_id: &str, + run_id: &str, + after_seq: i64, + limit: usize, + ) -> Result, ControlPlaneError>; + fn upsert_ai_external_conversation_binding( &self, input: UpsertAiExternalConversationBindingInput, diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js index 48022b5f..56f6a2c8 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js @@ -132,7 +132,7 @@ export function createSidebarPageAiProfileRuntime(context) { function pageAiSessionAgentLabel(session) { var agentId = pageAiNormalizeAgentId(session && session.agentId); - if (agentId === 'reasonix') return 'Reasonix'; + if (agentId === 'reasonix') return pageAiAgentRecord(agentId).label || 'Reasonix'; var profileId = String(session && (session.profileId || session.profile) || '').trim(); var profile = pageAiProfileRecordById(profileId) || { profileId: profileId, name: profileId }; var chatOnlySpec = pageAiChatOnlyProfileSpec(profile); diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js index 8ffb8d9f..c6ff7531 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js @@ -6,6 +6,7 @@ export function createSidebarPageAiRenderRuntime(context) { documentRef, escapeHtml, localMarkdownRelativePathFromPageAiDocumentId, + pageAiAgentDescriptor, pageAiAgentRecord, pageAiAllSkillEntries, pageAiBuildAllowedRoots, @@ -18,6 +19,8 @@ export function createSidebarPageAiRenderRuntime(context) { pageAiCurrentSkillSource, pageAiCurrentSkillSourceLabel, pageAiDefaultChatOnlyProfileSpec, + pageAiDescriptorFieldOptions, + pageAiDescriptorFieldValue, pageAiEditorTargetCandidates, pageAiEnsureContextRefState, pageAiFilteredHistoryRows, @@ -125,7 +128,9 @@ export function createSidebarPageAiRenderRuntime(context) { function pageAiCurrentAgentSelectionLabel() { var agentId = pageAiCurrentAgentId(); - if (agentId === 'reasonix') return 'Reasonix'; + var descriptor = pageAiAgentDescriptor(agentId); + var descriptorLabel = String(descriptor && (descriptor.displayName || descriptor.label) || '').trim(); + if (agentId === 'reasonix') return descriptorLabel || 'Reasonix'; var profile = pageAiCurrentProfileRecord(); var chatOnlySpec = pageAiChatOnlyProfileSpec(profile || pageAiCurrentProfile()); if (agentId === 'chat_only') { @@ -134,8 +139,8 @@ export function createSidebarPageAiRenderRuntime(context) { if (chatOnlySpec) { return 'ChatOnly / ' + chatOnlySpec.label; } - if (agentId === 'hermes') return 'Hermes / ' + pageAiProfileDisplayLabel(profile, pageAiCurrentProfile()); - return pageAiAgentRecord(agentId).label; + if (agentId === 'hermes') return (descriptorLabel || 'Hermes') + ' / ' + pageAiProfileDisplayLabel(profile, pageAiCurrentProfile()); + return descriptorLabel || pageAiAgentRecord(agentId).label; } function pageAiRenderAgentProfileOption(agentId, profile, label, detail) { @@ -148,6 +153,59 @@ export function createSidebarPageAiRenderRuntime(context) { ''; } + function pageAiAgentDescriptorDetail(descriptor) { + if (!descriptor) return ''; + var capabilities = pageAiNormalizeArray(descriptor.capabilities); + var toolCount = Number(descriptor.toolCount || pageAiNormalizeArray(descriptor.tools).length || 0); + var parts = []; + parts.push(descriptor.canWriteFiles === false ? '只读/聊天' : '可写授权文件'); + if (capabilities.length) parts.push('capability ' + capabilities.length); + if (toolCount) parts.push('tool ' + toolCount); + return parts.join(' · '); + } + + function pageAiRenderDescriptorField(field) { + var key = String(field && field.key || '').trim(); + if (!key || field.secret === true) return ''; + var label = String(field.label || key).trim(); + var kind = String(field.kind || 'string').trim(); + var value = pageAiDescriptorFieldValue(field); + var options = pageAiDescriptorFieldOptions(field); + var control = ''; + if (kind === 'select' || options.length) { + control = ''; + } else if (kind === 'boolean' || kind === 'toggle') { + control = ''; + } else { + control = ''; + } + return '' + + ''; + } + + function pageAiRenderAgentDescriptorCard(agentId, fallbackTitle) { + var descriptor = pageAiAgentDescriptor(agentId) || pageAiAgentRecord(agentId); + var title = String(descriptor && (descriptor.displayName || descriptor.label) || fallbackTitle || agentId).trim(); + var fields = pageAiNormalizeArray(descriptor && descriptor.fields).map(pageAiRenderDescriptorField).filter(Boolean).join(''); + var disabled = pageAiNormalizeArray(descriptor && descriptor.disabledCapabilities); + var disabledText = disabled.length ? '
disabled: ' + escapeHtml(disabled.join(', ')) + '
' : ''; + return '' + + '
' + + '
' + + '
' + escapeHtml(title) + '
' + escapeHtml(pageAiAgentDescriptorDetail(descriptor) || 'descriptor fallback') + '
' + + '
' + + disabledText + + (fields ? '
' + fields + '
' : '
当前 agent 没有可配置字段。
') + + '
'; + } + function pageAiCurrentModelLabel() { var profile = pageAiCurrentProfileRecord(); var toolModel = pageAiMnoteToolModel(); @@ -277,6 +335,9 @@ export function createSidebarPageAiRenderRuntime(context) { return pageAiRenderAgentProfileOption('hermes', profile, pageAiProfileDisplayLabel(profile, profileId), scope); }).join(''); var reasonixActive = activeAgentId === 'reasonix'; + var reasonixDescriptor = pageAiAgentDescriptor('reasonix'); + var reasonixLabel = String(reasonixDescriptor && (reasonixDescriptor.displayName || reasonixDescriptor.label) || 'Reasonix').trim() || 'Reasonix'; + var reasonixDetail = pageAiAgentDescriptorDetail(reasonixDescriptor) || '可读取并在授权目录内写文件'; agentPopover.innerHTML = '' + '
' + '选择 Agent' + @@ -294,8 +355,8 @@ export function createSidebarPageAiRenderRuntime(context) { '
Reasonix
' + '
' + '' + '
' + '
'; @@ -414,9 +475,11 @@ export function createSidebarPageAiRenderRuntime(context) { var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]'); if (agentPanel instanceof HTMLElement) { agentPanel.innerHTML = '' + + (pageUiState.pageAiAgentDescriptorError ? '
' + escapeHtml(pageUiState.pageAiAgentDescriptorError) + '
' : '') + '
' + '
授权区域
' + escapeHtml(pageAiBuildAllowedRoots().length ? 'SQLite directory_grants' : '需要授权') + '
' + '
' + + pageAiRenderAgentDescriptorCard(activeAgentId, pageAiCurrentAgentSelectionLabel()) + '
' + '
默认上下文
ContextRefs
' + '
' + @@ -512,7 +575,7 @@ export function createSidebarPageAiRenderRuntime(context) { } var hermesMemoryPanel = drawer.querySelector('[data-page-ai-hermes-panel]'); if (hermesMemoryPanel instanceof HTMLElement) { - hermesMemoryPanel.innerHTML = ['soul', 'user', 'memory'].map(function(section) { + hermesMemoryPanel.innerHTML = pageAiRenderAgentDescriptorCard('hermes', 'Hermes') + ['soul', 'user', 'memory'].map(function(section) { var label = pageAiMemoryFileLabel(section); var value = pageUiState.pageAiProfileMemoryDrafts[section] || ''; return '' + @@ -713,6 +776,7 @@ export function createSidebarPageAiRenderRuntime(context) { var reasonixPanel = drawer.querySelector('[data-page-ai-reasonix-panel]'); if (reasonixPanel instanceof HTMLElement) { reasonixPanel.innerHTML = '' + + pageAiRenderAgentDescriptorCard('reasonix', 'Reasonix') + '
' + '
' + '
Reasonix 专属设置
ACP runtime / memory
' + @@ -723,7 +787,8 @@ export function createSidebarPageAiRenderRuntime(context) { } var chatOnlyPanel = drawer.querySelector('[data-page-ai-chat-only-panel]'); if (chatOnlyPanel instanceof HTMLElement) { - chatOnlyPanel.innerHTML = '
Chat-only
默认不申请文件写权限
'; + chatOnlyPanel.innerHTML = pageAiRenderAgentDescriptorCard('chat_only', 'Chat-only') + + '
Chat-only
默认不申请文件写权限
'; } } diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js index e9aed035..a3d22b4c 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -25,11 +25,12 @@ export function createSidebarPageAiRuntime(context) { // Page AI 请求合同:agentId 只负责路由;contextRefs 是用户勾选的上下文地址; // allowedRoots 只展示 SQLite directory_grants 解析结果,服务端仍会按当前用户重算; // runTargetSnapshot 必须来自 OpenEditorsSnapshot,避免运行中 UI 切换导致目标漂移。 - var PAGE_AI_AGENT_REGISTRY = [ + var PAGE_AI_AGENT_FALLBACK_REGISTRY = [ { id: 'hermes', label: 'Hermes', acpRuntime: 'hermes', canWriteFiles: true }, { id: 'reasonix', label: 'Reasonix', acpRuntime: 'reasonix', canWriteFiles: true }, { id: 'chat_only', label: 'Chat-only', acpRuntime: 'hermes', canWriteFiles: false } ]; + var PAGE_AI_DESCRIPTOR_SCHEMA = 'mnote.ai_agent_descriptor.v1'; var PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY = [ { profileId: 'shared_deepseek_chat', baseProfile: 'deepseek-chat', label: 'DeepSeek' }, { profileId: 'shared_doubao_chat', baseProfile: 'doubao-chat', label: '豆包' }, @@ -151,6 +152,9 @@ export function createSidebarPageAiRuntime(context) { pageAiAllowedRootsError: '', pageAiAgentId: 'reasonix', pageAiAgentPopoverOpen: false, + pageAiAgentDescriptors: [], + pageAiAgentDescriptorError: '', + pageAiAgentDescriptorsLoaded: false, pageAiTools: [], pageAiToolsError: '', pageAiGatewayHealth: null, @@ -354,14 +358,45 @@ export function createSidebarPageAiRuntime(context) { }; } + function pageAiNormalizeAgentDescriptor(descriptor) { + if (!descriptor || typeof descriptor !== 'object') return null; + var agentId = String(descriptor.agentId || descriptor.id || '').trim(); + if (!agentId) return null; + return Object.assign({}, descriptor, { + id: agentId, + agentId: agentId, + label: String(descriptor.displayName || descriptor.label || agentId).trim() || agentId, + acpRuntime: String(descriptor.acpRuntime || descriptor.acp_runtime || '').trim() || (agentId === 'reasonix' ? 'reasonix' : 'hermes'), + canWriteFiles: descriptor.canWriteFiles !== false, + capabilities: pageAiNormalizeArray(descriptor.capabilities), + fields: pageAiNormalizeArray(descriptor.fields), + tools: pageAiNormalizeArray(descriptor.tools), + capabilityPacks: pageAiNormalizeArray(descriptor.capabilityPacks), + schema: String(descriptor.schema || PAGE_AI_DESCRIPTOR_SCHEMA).trim() + }); + } + + function pageAiAgentRegistry() { + var descriptors = pageAiNormalizeArray(pageUiState.pageAiAgentDescriptors) + .map(pageAiNormalizeAgentDescriptor) + .filter(Boolean); + return descriptors.length ? descriptors : PAGE_AI_AGENT_FALLBACK_REGISTRY; + } + + function pageAiAgentDescriptor(agentId) { + var normalized = String(agentId || '').trim(); + if (!normalized) normalized = pageAiCurrentAgentId(); + return pageAiAgentRegistry().find(function(agent) { return agent.id === normalized || agent.agentId === normalized; }) || null; + } + function pageAiNormalizeAgentId(agentId) { var value = String(agentId || '').trim(); - return PAGE_AI_AGENT_REGISTRY.some(function(agent) { return agent.id === value; }) ? value : 'reasonix'; + return pageAiAgentRegistry().some(function(agent) { return agent.id === value; }) ? value : 'reasonix'; } function pageAiAgentRecord(agentId) { var normalized = pageAiNormalizeAgentId(agentId); - return PAGE_AI_AGENT_REGISTRY.find(function(agent) { return agent.id === normalized; }) || PAGE_AI_AGENT_REGISTRY[1]; + return pageAiAgentRegistry().find(function(agent) { return agent.id === normalized; }) || pageAiAgentRegistry()[1] || PAGE_AI_AGENT_FALLBACK_REGISTRY[1]; } function pageAiCurrentAgentId() { @@ -460,6 +495,7 @@ export function createSidebarPageAiRuntime(context) { const pageAiRenameBackendSession = (...args) => pageAiSessionRuntime.pageAiRenameBackendSession(...args); const pageAiDeleteBackendSession = (...args) => pageAiSessionRuntime.pageAiDeleteBackendSession(...args); const pageAiResumeBackendSession = (...args) => pageAiSessionRuntime.pageAiResumeBackendSession(...args); + const pageAiCheckActiveRun = (...args) => pageAiSessionRuntime.pageAiCheckActiveRun(...args); const pageAiSkillRuntime = createSidebarPageAiSkillRuntime({ pageAiCurrentAgentId, pageAiCurrentProfile, @@ -561,6 +597,7 @@ export function createSidebarPageAiRuntime(context) { documentRef: document, escapeHtml, localMarkdownRelativePathFromPageAiDocumentId, + pageAiAgentDescriptor, pageAiAgentRecord, pageAiAllSkillEntries, pageAiBuildAllowedRoots, @@ -573,6 +610,8 @@ export function createSidebarPageAiRuntime(context) { pageAiCurrentSkillSource, pageAiCurrentSkillSourceLabel, pageAiDefaultChatOnlyProfileSpec, + pageAiDescriptorFieldOptions, + pageAiDescriptorFieldValue, pageAiEditorTargetCandidates, pageAiEnsureContextRefState, pageAiFilteredHistoryRows, @@ -611,6 +650,7 @@ export function createSidebarPageAiRuntime(context) { var record = pageAiAgentRecord(next); pageUiState.pageAiAgentId = next; pageUiState.pageAiAcpRuntime = record.acpRuntime || 'reasonix'; + if (record.tools) pageUiState.pageAiTools = record.tools; pageUiState.pageAiAgentPopoverOpen = next === 'hermes'; if (next === 'hermes') pageAiSetActiveProfile(pageAiCurrentProfile()); if (next === 'chat_only') pageAiSetActiveProfile(pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile())); @@ -692,6 +732,114 @@ export function createSidebarPageAiRuntime(context) { } catch (_) {} } + function pageAiDescriptorPreferenceValues() { + return pageUiState.pageAiSkillPreferences && typeof pageUiState.pageAiSkillPreferences === 'object' + ? pageUiState.pageAiSkillPreferences + : {}; + } + + function pageAiDescriptorFieldValue(field) { + var key = String(field && field.key || '').trim(); + var preferences = pageAiDescriptorPreferenceValues(); + if (key && Object.prototype.hasOwnProperty.call(preferences, key)) return preferences[key]; + return field && Object.prototype.hasOwnProperty.call(field, 'default') ? field.default : ''; + } + + function pageAiDescriptorFieldOptions(field) { + var source = String(field && field.optionsSource || '').trim(); + if (source === 'hermes_profiles') { + return pageAiHermesProfileEntries().map(function(profile) { + var value = pageAiProfileValue(profile); + return { + value: value, + label: pageAiProfileDisplayLabel(profile, value) + }; + }).filter(function(option) { return option.value; }); + } + if (source === 'chat_only_profiles') { + return pageAiChatOnlyProfileEntries().map(function(profile) { + var value = pageAiProfileValue(profile); + return { + value: value, + label: profile.menuLabel || pageAiProfileDisplayLabel(profile, value) + }; + }).filter(function(option) { return option.value; }); + } + return pageAiNormalizeArray(field && field.options).map(function(option) { + if (option && typeof option === 'object') { + var value = String(option.value || option.id || option.key || '').trim(); + return { + value: value, + label: String(option.label || option.title || value).trim() || value + }; + } + var value = String(option || '').trim(); + return { value: value, label: value }; + }).filter(function(option) { return option.value; }); + } + + function pageAiSetDescriptorFieldValue(key, value) { + var normalizedKey = String(key || '').trim(); + if (!normalizedKey) return; + pageUiState.pageAiSkillPreferences = Object.assign({}, pageAiDescriptorPreferenceValues(), { + [normalizedKey]: value + }); + pageAiPersistRawAiPreference(normalizedKey, value); + if (normalizedKey === 'ai.agent.hermes.profile_id') { + pageAiSetActiveProfile(value); + void pageAiLoadAgentDescriptors(); + void pageAiLoadSkills(); + void pageAiLoadTools(); + } + if (normalizedKey === 'ai.agent.chat_only.model_id' && pageAiCurrentAgentId() === 'chat_only') { + pageAiSetActiveProfile(pageAiNormalizeChatOnlyProfileId(value)); + } + renderPageAiControls(); + } + + function pageAiApplyAgentDescriptorsPayload(payload) { + var descriptors = pageAiNormalizeArray(payload && payload.descriptors) + .map(pageAiNormalizeAgentDescriptor) + .filter(function(descriptor) { return descriptor.schema === PAGE_AI_DESCRIPTOR_SCHEMA; }); + if (!descriptors.length) return false; + pageUiState.pageAiAgentDescriptors = descriptors; + pageUiState.pageAiAgentDescriptorsLoaded = true; + pageUiState.pageAiAgentDescriptorError = ''; + if (payload && payload.preferenceValues && typeof payload.preferenceValues === 'object') { + pageUiState.pageAiSkillPreferences = Object.assign( + {}, + pageAiDescriptorPreferenceValues(), + payload.preferenceValues + ); + } + var current = pageAiCurrentAgentId(); + var record = pageAiAgentRecord(current); + pageUiState.pageAiAgentId = record.id; + pageUiState.pageAiAcpRuntime = record.acpRuntime || pageUiState.pageAiAcpRuntime || 'reasonix'; + if (record.tools) pageUiState.pageAiTools = record.tools; + return true; + } + + async function pageAiLoadAgentDescriptors() { + try { + var url = new URL('/api/page-ai/agents/descriptors', window.location.origin); + url.searchParams.set('profile', pageAiCurrentProfile()); + var response = await fetch(url.toString(), { + headers: { accept: 'application/json' }, + cache: 'no-store' + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'agent_descriptors_failed_' + response.status)); + if (!pageAiApplyAgentDescriptorsPayload(payload)) throw new Error('agent_descriptors_empty'); + } catch (error) { + pageUiState.pageAiAgentDescriptorError = error instanceof Error ? error.message : String(error); + pageUiState.pageAiAgentDescriptorsLoaded = false; + } + renderPageAiProviderButtons(); + renderPageAiControls(); + return pageUiState.pageAiAgentDescriptors; + } + async function pageAiLoadAiPreferences() { try { var url = new URL('/api/ui/preferences/effective', window.location.origin); @@ -711,6 +859,7 @@ export function createSidebarPageAiRuntime(context) { var agent = pageAiAgentRecord(defaultAgent); pageUiState.pageAiAgentId = agent.id; pageUiState.pageAiAcpRuntime = agent.acpRuntime || pageUiState.pageAiAcpRuntime || 'reasonix'; + if (agent.tools) pageUiState.pageAiTools = agent.tools; } var hermesProfile = String(preferences['ai.agent.hermes.profile_id'] || '').trim(); if (hermesProfile) pageAiSetActiveProfile(hermesProfile); @@ -1136,6 +1285,7 @@ export function createSidebarPageAiRuntime(context) { if (pageUiState.pageAiCurrentRunId) { document.documentElement.setAttribute('data-mnote-page-ai-run-id', pageUiState.pageAiCurrentRunId); } + pageAiUpdateActiveRunSnapshotFromStatus(pageUiState.pageAiRunStatus, pageUiState.pageAiCurrentRunId); } function pageAiApplyRuntimeState(runtime) { @@ -1157,6 +1307,74 @@ export function createSidebarPageAiRuntime(context) { } } + function pageAiActiveRunSnapshotKey() { + return pageAiStorageKey() + ':active-run'; + } + + function pageAiReadActiveRunSnapshot() { + try { + var raw = window.localStorage ? window.localStorage.getItem(pageAiActiveRunSnapshotKey()) : ''; + return raw ? JSON.parse(raw) : null; + } catch (_) { + return null; + } + } + + function pageAiWriteActiveRunSnapshot(snapshot) { + if (!snapshot || !snapshot.hostRunId) return; + try { + window.localStorage.setItem(pageAiActiveRunSnapshotKey(), JSON.stringify(Object.assign({ + schema: 'mnote.page_ai_active_run_snapshot.v1', + createdAt: Date.now() + }, snapshot, { + updatedAt: Date.now() + }))); + document.documentElement.setAttribute('data-mnote-page-ai-active-run-snapshot', 'true'); + } catch (_) {} + } + + function pageAiClearActiveRunSnapshot(runId) { + var existing = pageAiReadActiveRunSnapshot(); + if (runId && existing && existing.hostRunId && existing.hostRunId !== runId) return; + try { + if (window.localStorage) window.localStorage.removeItem(pageAiActiveRunSnapshotKey()); + document.documentElement.removeAttribute('data-mnote-page-ai-active-run-snapshot'); + } catch (_) {} + } + + function pageAiRunStatusIsTerminal(status) { + return ['completed', 'failed', 'aborted', 'cancelled', 'canceled'].indexOf(String(status || '').trim()) >= 0; + } + + function pageAiUpdateActiveRunSnapshotFromStatus(status, runId) { + var id = String(runId || '').trim(); + if (!id) return; + if (pageAiRunStatusIsTerminal(status)) { + pageAiClearActiveRunSnapshot(id); + return; + } + if (['running', 'tool_calling', 'queued', 'pending', 'interrupted', 'acp_pending'].indexOf(String(status || '').trim()) < 0) return; + var previous = pageAiReadActiveRunSnapshot(); + var lastSeq = previous && previous.hostRunId === id ? String(previous.lastSeq || '000000000000000000') : '000000000000000000'; + pageAiWriteActiveRunSnapshot({ + hostRunId: id, + sessionId: pageUiState.pageAiActiveSessionId || '', + lastSeq: lastSeq, + status: status || 'running' + }); + } + + async function pageAiCheckAndResumeActiveRun() { + var snapshot = pageAiReadActiveRunSnapshot(); + var sessionId = snapshot && snapshot.sessionId ? String(snapshot.sessionId || '').trim() : ''; + var run = await pageAiCheckActiveRun(sessionId || undefined); + if (run && sessionId && pageUiState.pageAiActiveSessionId !== sessionId) { + pageAiSetActiveSession(sessionId); + } + if (run) await pageAiResumeActiveRunJournal(run); + return run; + } + function pageAiApplyQueuedRun(payload) { if (!payload || payload.queued !== true) return false; var queueId = String(payload.queueId || payload.queue_id || '').trim(); @@ -1540,6 +1758,13 @@ export function createSidebarPageAiRuntime(context) { async function pageAiLoadTools() { try { + var descriptor = pageAiAgentDescriptor(pageAiCurrentAgentId()); + if (descriptor && pageUiState.pageAiAgentDescriptorsLoaded) { + pageUiState.pageAiTools = pageAiNormalizeArray(descriptor.tools); + pageUiState.pageAiToolsError = ''; + renderPageAiControls(); + return; + } var response = await fetch('/api/hermes/client/tools?scope=mnote&profile=' + encodeURIComponent(pageAiCurrentProfile()), { headers: { 'accept': 'application/json' } }); @@ -1646,12 +1871,14 @@ export function createSidebarPageAiRuntime(context) { var active = pageAiProfileValue(pageUiState.pageAiProfiles.find(function(profile) { return profile.active; })); pageAiSetActiveProfile(hasCurrent && current !== 'default' ? current : (active || pageAiProfileValue(personal) || pageAiProfileValue(pageUiState.pageAiProfiles[0]) || current)); pageUiState.pageAiProfileError = ''; + void pageAiLoadAgentDescriptors(); void pageAiLoadGatewayHealth(); } catch (error) { pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ profileId: 'shared_lite', name: 'shared_lite', alias: 'Lite', kind: 'shared', readonly: true, canManageSkills: false }]; pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(pageUiState.pageAiAcpRuntimes); pageAiSetActiveProfile(pageAiCurrentProfile()); + void pageAiLoadAgentDescriptors(); } renderPageAiProviderButtons(); renderPageAiControls(); @@ -1683,6 +1910,7 @@ export function createSidebarPageAiRuntime(context) { pageAiPersistSessions(); await pageAiEnsureHermesSession(true); await pageAiLoadProfileMemory(); + await pageAiLoadAgentDescriptors(); await pageAiLoadSkills(); await pageAiLoadTools(); await pageAiLoadGatewayHealth(); @@ -1907,9 +2135,20 @@ export function createSidebarPageAiRuntime(context) { drawer.hidden = false; pageUiState.pageAiOpen = true; updatePageAiTriggerState(); + void pageAiCheckAndResumeActiveRun().catch(function(error) { + pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); + renderPageAiControls(); + }); + void pageAiLoadBackendSessions().then(function() { + return pageAiCheckAndResumeActiveRun(); + }).catch(function(error) { + pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); + renderPageAiControls(); + }); Promise.all([ pageAiLoadProfiles(), pageAiLoadProfileMemory(), + pageAiLoadAgentDescriptors(), pageAiLoadSkills(), pageAiLoadTools(), pageAiLoadGatewayHealth(), @@ -1918,6 +2157,12 @@ export function createSidebarPageAiRuntime(context) { pageAiLoadBackendSessions() ]).then(function() { renderPageAiControls(); + }).then(function() { + return pageAiCheckAndResumeActiveRun().catch(function(error) { + pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); + renderPageAiControls(); + return null; + }); }).then(function() { return pageAiRestoreHermesSession(); }).then(function() { @@ -1983,6 +2228,27 @@ export function createSidebarPageAiRuntime(context) { } } + function pageAiEventSeqFromPayloadText(payloadText) { + try { + var payload = JSON.parse(payloadText || 'null'); + return String((payload && payload.seq) || '').trim(); + } catch (_) { + return ''; + } + } + + function pageAiTrackStreamingRunEvent(runId, eventName, payloadText) { + var seq = pageAiEventSeqFromPayloadText(payloadText); + if (!seq) return; + pageAiWriteActiveRunSnapshot({ + hostRunId: runId, + sessionId: pageUiState.pageAiActiveSessionId || '', + lastSeq: seq, + status: pageAiRunStatusIsTerminal(eventName) ? pageUiState.pageAiRunStatus : (pageUiState.pageAiRunStatus || 'running') + }); + document.documentElement.setAttribute('data-mnote-page-ai-active-run-last-seq', seq); + } + function pageAiEnsureStreamingAssistantMessage(runId) { var id = String(runId || pageUiState.pageAiCurrentRunId || ''); var existing = pageUiState.pageAiMessages.find(function(item) { @@ -2318,6 +2584,90 @@ export function createSidebarPageAiRuntime(context) { }); } + function pageAiJournalPayloadText(event) { + try { + return JSON.stringify(event && event.payload && typeof event.payload === 'object' ? event.payload : {}); + } catch (_) { + return '{}'; + } + } + + function pageAiLastUserPrompt() { + var messages = pageAiNormalizeArray(pageUiState.pageAiMessages); + for (var index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index] && messages[index].role === 'user') return String(messages[index].content || ''); + } + return ''; + } + + function pageAiApplyRunJournalEvent(event, runId, runTraceId) { + if (!event || typeof event !== 'object') return; + var eventName = String(event.kind || event.eventType || event.event || '').trim(); + var payload = event.payload && typeof event.payload === 'object' ? event.payload : {}; + var payloadText = pageAiJournalPayloadText(event); + var prompt = pageAiLastUserPrompt(); + if (eventName === 'message.delta') { + pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText)); + } else if (eventName === 'run.completed') { + pageAiFinishStreamingAssistantMessage(runId, String(payload.output || ''), prompt, []); + pageAiSetRunStatus('completed', runId); + } else if (eventName === 'run.failed') { + pageAiFinishStreamingAssistantMessage(runId, String(payload.message || payload.code || payload.error || 'AI run failed'), prompt, []); + pageAiSetRunStatus('failed', runId); + } else if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') { + pageUiState.pageAiStoppedRunIds[runId] = true; + pageAiSetRunStatus('aborted', runId); + } else if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { + var toolItem = pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId); + if (eventName === 'tool.completed') { + var citations = pageAiCollectKnowledgeRagCitations(toolItem, payload); + if (citations.length) pageAiFinishStreamingAssistantMessage(runId, '', prompt, citations); + } + pageAiSetRunStatus('tool_calling', runId); + } else if (eventName === 'plan.updated') { + var planEntries = Array.isArray(payload.entries) ? payload.entries : []; + if (planEntries.length) { + pageUiState.pageAiMessages.push({ role: 'system', kind: 'plan', entries: planEntries, createdAt: Date.now(), updatedAt: Date.now() }); + } + } + var seq = String(event.seq || '').trim(); + if (seq) { + pageAiWriteActiveRunSnapshot({ + hostRunId: runId, + sessionId: pageUiState.pageAiActiveSessionId || String(event.sessionId || ''), + lastSeq: seq, + status: pageUiState.pageAiRunStatus || 'running' + }); + document.documentElement.setAttribute('data-mnote-page-ai-active-run-last-seq', seq); + } + pageAiSyncCurrentSessionMessages(); + pageAiPersistSessions(); + renderPageAiConversation(); + renderPageAiControls(); + } + + async function pageAiResumeActiveRunJournal(run) { + if (!run || typeof run !== 'object') return; + var runId = String(run.hostRunId || run.runId || '').trim(); + if (!runId) return; + var snapshot = pageAiReadActiveRunSnapshot(); + var afterSeq = snapshot && snapshot.hostRunId === runId ? String(snapshot.lastSeq || '000000000000000000') : '000000000000000000'; + var response = await fetch('/api/page-ai/runs/' + encodeURIComponent(runId) + '/events?afterSeq=' + encodeURIComponent(afterSeq), { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok || !payload || payload.ok !== true) { + throw new Error(pageAiErrorMessage(payload, 'run_journal_failed_' + response.status)); + } + var runTraceId = String((payload.run && payload.run.traceId) || (run && run.traceId) || ''); + pageAiNormalizeArray(payload.events).forEach(function(event) { + pageAiApplyRunJournalEvent(event, runId, runTraceId); + }); + var status = String((payload.run && payload.run.status) || run.status || '').trim(); + if (pageAiRunStatusIsTerminal(status)) pageAiClearActiveRunSnapshot(runId); + document.documentElement.setAttribute('data-mnote-page-ai-run-journal-resumed', runId); + } + function pageAiLooksLikeBlockEdit(prompt) { var text = searchText(prompt); return ['新增', '添加', '插入', '删除', '删掉', '修改', '替换', '改成', '移动', '移到', 'move', 'replace', 'delete', 'insert'].some(function(word) { @@ -2537,6 +2887,7 @@ export function createSidebarPageAiRuntime(context) { var assistantText = ''; var autoCitations = []; await streamPageAiResponse(eventResponse, function(eventName, payloadText) { + pageAiTrackStreamingRunEvent(runId, eventName, payloadText); if (eventName === 'message.delta') { if (pageUiState.pageAiStoppedRunIds[runId]) return; assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText)); @@ -3002,6 +3353,16 @@ export function createSidebarPageAiRuntime(context) { function handlePageAiChange(event, helpers) { var closestAction = helpers && helpers.closestAction; if (typeof closestAction !== 'function') return false; + var pageAiDescriptorField = closestAction(event.target, '[data-page-ai-descriptor-field]'); + if (pageAiDescriptorField instanceof HTMLInputElement || pageAiDescriptorField instanceof HTMLSelectElement) { + var key = pageAiDescriptorField.getAttribute('data-page-ai-descriptor-field') || ''; + var kind = pageAiDescriptorField.getAttribute('data-page-ai-descriptor-kind') || ''; + var value = kind === 'boolean' && pageAiDescriptorField instanceof HTMLInputElement + ? pageAiDescriptorField.checked + : pageAiDescriptorField.value; + pageAiSetDescriptorFieldValue(key, value); + return true; + } var pageAiAcpRuntimeSelect = closestAction(event.target, '[data-page-ai-acp-runtime]'); if (pageAiAcpRuntimeSelect instanceof HTMLSelectElement) { var next = String(pageAiAcpRuntimeSelect.value || 'reasonix').trim() || 'reasonix'; @@ -3074,6 +3435,13 @@ export function createSidebarPageAiRuntime(context) { document.addEventListener('change', function(event) { handlePageAiChange(event, helpers); }); + document.addEventListener('visibilitychange', function() { + if (document.visibilityState !== 'visible' || !pageUiState.pageAiOpen) return; + void pageAiCheckAndResumeActiveRun().catch(function(error) { + pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); + renderPageAiControls(); + }); + }); } diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js index 1db23199..332a6283 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js @@ -627,6 +627,37 @@ export function createSidebarPageAiSessionRuntime(context) { renderPageAiControls(); } + async function pageAiCheckActiveRun(sessionId) { + sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim(); + if (!sessionId) return null; + var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/active-run?' + pageAiBackendSessionQuery({}), { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok || !payload || payload.ok !== true) { + throw new Error(pageAiErrorMessage(payload, 'active_run_failed_' + response.status)); + } + if (!payload.active || !payload.run) return null; + var run = payload.run; + var hostRunId = String(run.hostRunId || run.runId || '').trim(); + var status = String(run.status || '').trim(); + var current = pageAiCurrentSession(); + if (current && current.id === sessionId) { + current.runId = hostRunId; + current.status = status || current.status || 'running'; + current.updatedAt = Date.now(); + } + pageAiApplyRuntimeState(Object.assign({}, run.runtime || {}, { + status: status || 'running', + runId: hostRunId + })); + doc.documentElement.setAttribute('data-mnote-page-ai-active-run-checked', 'true'); + if (hostRunId) doc.documentElement.setAttribute('data-mnote-page-ai-active-host-run-id', hostRunId); + pageAiPersistSessions(); + renderPageAiControls(); + return run; + } + return { pageAiStorageKey, pageAiBackendSessionQuery, @@ -650,6 +681,7 @@ export function createSidebarPageAiSessionRuntime(context) { pageAiStartNewSession, pageAiRenameBackendSession, pageAiDeleteBackendSession, - pageAiResumeBackendSession + pageAiResumeBackendSession, + pageAiCheckActiveRun }; } diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index e7a1c4d2..f42b89c1 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -1360,6 +1360,385 @@ pub async fn list_capabilities( Ok((StatusCode::OK, stamp_client_headers(), Json(payload))) } +pub async fn list_agent_descriptors( + State(state): State, + Extension(context): Extension, + Query(query): Query>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + 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()); + let actor_id = page_ai_actor_id(&state, &context)?; + let preference_values = ai_preference_values(&state, &actor_id)?; + let descriptors = page_ai_agent_descriptors_payload(&state, &context, profile)?; + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "schema": "mnote.ai_agent_descriptors.v1", + "profile": profile, + "actorId": actor_id, + "preferenceValues": preference_values, + "descriptors": descriptors, + })), + )) +} + +pub async fn create_page_ai_run( + State(state): State, + Extension(context): Extension, + Json(mut payload): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let user_id = effective_session_store_user_id(&state, &context).await?; + let request_id = page_ai_run_request_id(&payload, &context)?; + let workspace_id = payload + .get("workspaceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| context.workspace.workspace_id.clone()); + let document_id = payload + .get("documentId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let session_id = payload + .get("sessionId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| { + stable_session_id(document_id.as_deref().unwrap_or("current"), &request_id) + }); + + if let Some(existing) = find_page_ai_run_by_request_id( + &state, + &user_id, + workspace_id.as_deref(), + document_id.as_deref(), + Some(&session_id), + &request_id, + )? { + return Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "schema": "mnote.ai_run_receipt.v1", + "idempotent": true, + "created": false, + "hostRunId": existing.run_id, + "requestId": request_id, + "run": page_ai_run_to_journal_json(&existing), + })), + )); + } + + let agent_id = payload + .get("agentId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("reasonix") + .to_string(); + let profile = payload + .get("profile") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(agent_id.as_str()) + .to_string(); + let acp_runtime = payload + .get("acpRuntime") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| page_ai_default_acp_runtime(&agent_id).to_string()); + let host_run_id = payload + .get("hostRunId") + .or_else(|| payload.get("runId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| new_page_ai_host_run_id(&request_id)); + payload["requestId"] = Value::String(request_id.clone()); + payload["hostRunId"] = Value::String(host_run_id.clone()); + payload["runId"] = Value::String(host_run_id.clone()); + payload["agentId"] = Value::String(agent_id.clone()); + payload["sessionId"] = Value::String(session_id.clone()); + payload["actorId"] = Value::String(user_id.clone()); + if let Some(workspace_id) = workspace_id.as_deref() { + payload["workspaceId"] = Value::String(workspace_id.to_string()); + } + if let Some(document_id) = document_id.as_deref() { + payload["documentId"] = Value::String(document_id.to_string()); + } + + let now = now_ms(); + let runtime_state = json!({ + "schema": "mnote.ai_run_runtime.v1", + "hostRunId": host_run_id, + "requestId": request_id, + "providerRunId": Value::Null, + "status": "pending", + "createdAt": now, + "updatedAt": now, + }); + let run = state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: user_id.clone(), + workspace_id: workspace_id.clone(), + document_id: document_id.clone(), + session_id: session_id.clone(), + run_id: host_run_id.clone(), + title: payload + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + profile, + acp_runtime: acp_runtime.clone(), + trace_id: Some(context.trace.trace_id.clone()), + status: "pending".to_string(), + runtime_json: json_string(&runtime_state), + payload_json: json_string(&payload), + }) + .map_err(|error| { + WebError::internal(format!("SQLite Page AI run 创建失败: {error}")) + .with_context(&context) + })?; + state + .control_plane() + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id, + workspace_id, + document_id, + session_id, + run_id: host_run_id.clone(), + profile: run.profile.clone(), + acp_runtime, + event_type: "run.created".to_string(), + payload_json: json_string(&json!({ + "schema": "mnote.ai_run_event_payload.v1", + "requestId": request_id, + "hostRunId": host_run_id, + "status": "pending", + })), + }) + .map_err(|error| { + WebError::internal(format!("SQLite Page AI run 初始事件写入失败: {error}")) + .with_context(&context) + })?; + + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "schema": "mnote.ai_run_receipt.v1", + "idempotent": false, + "created": true, + "hostRunId": run.run_id, + "requestId": request_id, + "run": page_ai_run_to_journal_json(&run), + })), + )) +} + +pub async fn get_page_ai_run( + State(state): State, + Extension(context): Extension, + Path(host_run_id): Path, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let user_id = effective_session_store_user_id(&state, &context).await?; + let Some(run) = state + .control_plane() + .find_ai_runtime_run(&user_id, &host_run_id) + .map_err(|error| { + WebError::internal(format!("SQLite Page AI run 读取失败: {error}")) + .with_context(&context) + })? + else { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "page_ai_run_not_found", + "Page AI run 不存在或无权访问", + ) + .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, + "schema": "mnote.ai_run.v1", + "run": page_ai_run_to_journal_json(&run), + })), + )) +} + +pub async fn list_page_ai_run_events( + State(state): State, + Extension(context): Extension, + Path(host_run_id): Path, + Query(query): Query>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let user_id = effective_session_store_user_id(&state, &context).await?; + let after_seq = query + .get("afterSeq") + .or_else(|| query.get("after_seq")) + .and_then(|value| parse_ai_run_seq(value)) + .unwrap_or(0); + let limit = query + .get("limit") + .and_then(|value| value.parse::().ok()) + .unwrap_or(200) + .clamp(1, 500); + let Some(run) = state + .control_plane() + .find_ai_runtime_run(&user_id, &host_run_id) + .map_err(|error| { + WebError::internal(format!("SQLite Page AI run 读取失败: {error}")) + .with_context(&context) + })? + else { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "page_ai_run_not_found", + "Page AI run 不存在或无权访问", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")); + }; + let events = state + .control_plane() + .list_ai_runtime_journal_events(&user_id, &host_run_id, after_seq, limit) + .map_err(|error| { + WebError::internal(format!( + "SQLite Page AI run event journal 读取失败: {error}" + )) + .with_context(&context) + })?; + let all_events = state + .control_plane() + .list_ai_runtime_journal_events(&user_id, &host_run_id, 0, 500) + .map_err(|error| { + WebError::internal(format!( + "SQLite Page AI run event journal 终态检查失败: {error}" + )) + .with_context(&context) + })?; + let mut event_values = events + .iter() + .map(page_ai_journal_event_to_json) + .collect::>(); + let mut next_seq = events.last().map(|event| event.seq).unwrap_or(after_seq); + if let Some(terminal_kind) = page_ai_terminal_event_kind(&run.status) { + let has_terminal = all_events + .iter() + .any(|event| page_ai_terminal_event_kind(&event.event.event_type).is_some()); + if !has_terminal && event_values.len() < limit { + let synthetic_seq = all_events + .last() + .map(|event| event.seq + 1) + .unwrap_or(1) + .max(1); + if synthetic_seq > after_seq { + next_seq = next_seq.max(synthetic_seq); + event_values.push(page_ai_synthetic_terminal_event_to_json( + &run, + terminal_kind, + synthetic_seq, + )); + } + } + } + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "schema": "mnote.ai_run_events.v1", + "hostRunId": host_run_id, + "afterSeq": format_ai_run_seq(after_seq), + "nextSeq": format_ai_run_seq(next_seq), + "hasMore": events.len() == limit, + "run": page_ai_run_to_journal_json(&run), + "events": event_values, + })), + )) +} + +pub async fn get_page_ai_session_active_run( + State(state): State, + Extension(context): Extension, + Path(session_id): Path, + Query(query): Query>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let user_id = effective_session_store_user_id(&state, &context).await?; + let workspace_id = query + .get("workspaceId") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| context.workspace.workspace_id.clone()); + let document_id = query + .get("documentId") + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let runs = state + .control_plane() + .list_ai_runtime_runs( + &user_id, + workspace_id.as_deref(), + document_id.as_deref(), + Some(&session_id), + 20, + ) + .map_err(|error| { + WebError::internal(format!("SQLite Page AI active run 读取失败: {error}")) + .with_context(&context) + })?; + let active_run = runs + .iter() + .find(|run| page_ai_run_status_is_active(&run.status)) + .map(page_ai_run_to_journal_json); + Ok(( + StatusCode::OK, + stamp_client_headers(), + Json(json!({ + "ok": true, + "schema": "mnote.ai_active_run.v1", + "sessionId": session_id, + "active": active_run.is_some(), + "run": active_run.unwrap_or(Value::Null), + })), + )) +} + pub async fn toggle_capability( State(state): State, Extension(context): Extension, @@ -2297,6 +2676,21 @@ fn acp_sse_bytes(event: &crate::acp_bridge::SseEvent) -> axum::body::Bytes { axum::body::Bytes::from(format!("event: {}\ndata: {}\n\n", event.event, json_str)) } +fn attach_runtime_event_seq(event: &mut crate::acp_bridge::SseEvent, persistence: &Value) { + let seq = persistence + .get("seq") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(seq) = seq else { + return; + }; + if let Value::Object(map) = &mut event.data { + map.entry("seq".to_string()) + .or_insert_with(|| Value::String(seq.to_string())); + } +} + fn acp_single_event_response( context: &RequestContext, event: crate::acp_bridge::SseEvent, @@ -2515,8 +2909,8 @@ async fn acp_stream_events( tokio::spawn(async move { loop { match event_rx.recv().await { - Ok(event) => { - if let Err(error) = persist_acp_runtime_event( + Ok(mut event) => { + match persist_acp_runtime_event( &state_for_events, &context_for_events, &event_registration, @@ -2528,7 +2922,10 @@ async fn acp_stream_events( ) .await { - warn!(error = ?error, run_id = %run_id_for_events, event = %event.event, "ACP runtime event 持久化失败"); + Ok(persistence) => attach_runtime_event_seq(&mut event, &persistence), + Err(error) => { + warn!(error = ?error, run_id = %run_id_for_events, event = %event.event, "ACP runtime event 持久化失败"); + } } let json_str = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into()); @@ -2914,8 +3311,8 @@ async fn forward_api_chat_events( }], }; let mut should_stop = false; - for event in events { - if let Err(error) = persist_acp_runtime_event( + for mut event in events { + match persist_acp_runtime_event( state, context, registration, @@ -2927,7 +3324,10 @@ async fn forward_api_chat_events( ) .await { - warn!(error = ?error, run_id = %run_id, event = %event.event, "API Chat runtime event 持久化失败"); + Ok(persistence) => attach_runtime_event_seq(&mut event, &persistence), + Err(error) => { + warn!(error = ?error, run_id = %run_id, event = %event.event, "API Chat runtime event 持久化失败"); + } } let next_status = match event.event.as_str() { "run.completed" => { @@ -4328,6 +4728,345 @@ fn mnote_capabilities_payload( })) } +fn page_ai_agent_descriptors_payload( + state: &AppState, + context: &RequestContext, + profile: &str, +) -> Result, WebError> { + ["hermes", "reasonix", "chat_only"] + .iter() + .map(|agent_id| page_ai_agent_descriptor_payload(state, context, agent_id, profile)) + .collect() +} + +fn page_ai_agent_descriptor_payload( + state: &AppState, + context: &RequestContext, + agent_id: &str, + profile: &str, +) -> Result { + let capabilities_payload = mnote_capabilities_payload(state, context, Some(agent_id), profile)?; + let capability_packs = capabilities_payload + .get("categories") + .and_then(Value::as_array) + .into_iter() + .flatten() + .flat_map(|category| { + category + .get("capabilities") + .and_then(Value::as_array) + .into_iter() + .flatten() + }) + .cloned() + .collect::>(); + let mut tools = if agent_id == "chat_only" { + Vec::new() + } else { + mnote_tools_payload(profile) + }; + page_ai_apply_capability_pack_tool_policy(&capability_packs, &mut tools); + tools.sort_by(|left, right| { + left.get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .cmp( + right + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(), + ) + }); + let capability_ids = page_ai_agent_capability_ids(agent_id, &capability_packs); + let capability_states = page_ai_agent_capability_states(&capability_packs); + let disabled_capability_ids = capability_states + .as_object() + .into_iter() + .flat_map(|states| states.iter()) + .filter_map(|(alias, state)| { + (state.get("enabled").and_then(Value::as_bool) == Some(false)).then(|| alias.clone()) + }) + .collect::>(); + Ok(json!({ + "schema": "mnote.ai_agent_descriptor.v1", + "agentId": agent_id, + "displayName": page_ai_agent_display_name(agent_id), + "provider": page_ai_agent_provider(agent_id), + "acpRuntime": page_ai_agent_acp_runtime(agent_id), + "canWriteFiles": agent_id != "chat_only", + "capabilities": capability_ids, + "defaultContextRefs": page_ai_agent_default_context_refs(agent_id), + "settingScopes": page_ai_agent_setting_scopes(agent_id), + "fields": page_ai_agent_setting_fields(agent_id), + "capabilityStates": capability_states, + "disabledCapabilities": disabled_capability_ids, + "capabilityPacks": capability_packs, + "tools": tools, + "toolCount": tools.len(), + })) +} + +fn page_ai_agent_display_name(agent_id: &str) -> &'static str { + match agent_id { + "hermes" => "Hermes", + "reasonix" => "Reasonix", + "chat_only" => "Chat-only", + _ => "Unknown", + } +} + +fn page_ai_agent_provider(agent_id: &str) -> &'static str { + match agent_id { + "hermes" => "hermes_client", + "reasonix" => "acp_reasonix", + "chat_only" => "chat_only", + _ => "unknown", + } +} + +fn page_ai_agent_acp_runtime(agent_id: &str) -> &'static str { + match agent_id { + "reasonix" => "reasonix", + "hermes" | "chat_only" => "hermes", + _ => "unknown", + } +} + +fn page_ai_agent_default_context_refs(agent_id: &str) -> Value { + match agent_id { + "chat_only" => json!([]), + "hermes" => json!(["current_page", "active_editor"]), + "reasonix" => json!(["current_page", "active_editor", "folder"]), + _ => json!([]), + } +} + +fn page_ai_agent_setting_scopes(agent_id: &str) -> Value { + match agent_id { + "hermes" => json!(["ai.common", "ai.agent.hermes"]), + "reasonix" => json!(["ai.common", "ai.agent.reasonix"]), + "chat_only" => json!(["ai.common", "ai.agent.chat_only"]), + _ => json!(["ai.common"]), + } +} + +fn page_ai_agent_setting_fields(agent_id: &str) -> Value { + match agent_id { + "hermes" => json!([ + { + "key": "ai.agent.hermes.profile_id", + "label": "Hermes profile", + "kind": "select", + "default": "default", + "optionsSource": "hermes_profiles", + "auth": "user", + "secret": false + } + ]), + "reasonix" => json!([ + { + "key": "ai.agent.reasonix.skill_panel_filter", + "label": "Reasonix skill filter", + "kind": "string", + "default": "", + "auth": "user", + "secret": false + } + ]), + "chat_only" => json!([ + { + "key": "ai.agent.chat_only.model_id", + "label": "Chat-only model", + "kind": "select", + "default": "shared_api_deepseek_flash_chat", + "optionsSource": "chat_only_profiles", + "auth": "user", + "secret": false + } + ]), + _ => json!([]), + } +} + +fn page_ai_agent_capability_ids(agent_id: &str, capability_packs: &[Value]) -> Vec { + let mut ids = match agent_id { + "hermes" => vec!["mnote_tools".to_string()], + "reasonix" => vec![ + "file_read".to_string(), + "file_write".to_string(), + "native_patch".to_string(), + ], + "chat_only" => vec!["chat".to_string()], + _ => Vec::new(), + }; + if capability_packs.iter().any(|capability| { + capability.get("id").and_then(Value::as_str) == Some("mnote-knowledge-rag") + && capability + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or(true) + }) { + ids.push("knowledge_rag".to_string()); + } + ids +} + +fn page_ai_capability_alias(capability_id: &str) -> String { + match capability_id { + "mnote-knowledge-rag" => "knowledge_rag".to_string(), + value => value + .trim() + .strip_prefix("mnote-") + .unwrap_or(value.trim()) + .replace('-', "_"), + } +} + +fn page_ai_agent_capability_states(capability_packs: &[Value]) -> Value { + let mut states = serde_json::Map::new(); + for capability in capability_packs { + let Some(id) = capability.get("id").and_then(Value::as_str) else { + continue; + }; + let alias = page_ai_capability_alias(id); + states.insert( + alias, + json!({ + "id": id, + "enabled": capability.get("enabled").and_then(Value::as_bool).unwrap_or(true), + "status": capability.get("status").and_then(Value::as_str).unwrap_or("available"), + "toolNames": capability.get("toolNames").cloned().unwrap_or(Value::Null), + }), + ); + } + Value::Object(states) +} + +fn page_ai_apply_capability_pack_tool_policy(capability_packs: &[Value], tools: &mut [Value]) { + let disabled_tool_names = capability_packs + .iter() + .filter(|capability| capability.get("enabled").and_then(Value::as_bool) == Some(false)) + .flat_map(|capability| { + capability + .get("toolNames") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>() + }) + .collect::>(); + let enabled_tool_names = capability_packs + .iter() + .filter(|capability| capability.get("enabled").and_then(Value::as_bool) != Some(false)) + .flat_map(|capability| { + capability + .get("toolNames") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>() + }) + .collect::>(); + if disabled_tool_names.is_empty() { + return; + } + for tool in tools { + let name = tool + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + if !disabled_tool_names.contains(name) || enabled_tool_names.contains(name) { + continue; + } + tool["enabled"] = Value::Bool(false); + tool["status"] = Value::String("disabled".to_string()); + tool["unavailableReason"] = Value::String("当前 MNote capability 已关闭".to_string()); + } +} + +fn ai_preference_values(state: &AppState, actor_id: &str) -> Result { + let preferences = state + .control_plane() + .list_user_ui_preferences(actor_id, None, None) + .map_err(|error| WebError::internal(format!("SQLite AI 偏好读取失败: {error}")))?; + let mut values = serde_json::Map::new(); + for preference in preferences { + if !preference.key.starts_with("ai.common.") && !preference.key.starts_with("ai.agent.") { + continue; + } + let value = serde_json::from_str::(&preference.value_json).unwrap_or(Value::Null); + values.insert(preference.key, value); + } + Ok(Value::Object(values)) +} + +fn page_ai_run_request_id(payload: &Value, context: &RequestContext) -> Result { + payload + .get("requestId") + .or_else(|| payload.get("request_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| context.source.idempotency_key.clone()) + .ok_or_else(|| { + WebError::bad_request_code( + "page_ai_run_request_id_required", + "创建 Page AI host run 需要 requestId 或 x-idempotency-key", + ) + .with_context(context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") + }) +} + +fn page_ai_default_acp_runtime(agent_id: &str) -> &'static str { + match agent_id { + "reasonix" => "reasonix", + "chat_only" => "chat_only", + _ => "hermes", + } +} + +fn new_page_ai_host_run_id(request_id: &str) -> String { + format!( + "host_run_{}_{}", + sanitize_id_part(request_id), + uuid::Uuid::new_v4().simple() + ) +} + +fn find_page_ai_run_by_request_id( + state: &AppState, + user_id: &str, + workspace_id: Option<&str>, + document_id: Option<&str>, + session_id: Option<&str>, + request_id: &str, +) -> Result, WebError> { + let runs = state + .control_plane() + .list_ai_runtime_runs(user_id, workspace_id, document_id, session_id, 500) + .map_err(|error| WebError::internal(format!("SQLite Page AI run 幂等查询失败: {error}")))?; + Ok(runs.into_iter().find(|run| { + serde_json::from_str::(&run.payload_json) + .ok() + .and_then(|payload| { + payload + .get("requestId") + .or_else(|| payload.get("request_id")) + .and_then(Value::as_str) + .map(|value| value == request_id) + }) + .unwrap_or(false) + })) +} + fn ordered_mnote_capability_categories( mut categories: BTreeMap>, ) -> Vec<(String, Vec)> { @@ -8470,6 +9209,154 @@ fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Val }) } +fn page_ai_run_to_journal_json(record: &control_plane::AiRuntimeRunRecord) -> Value { + let runtime = serde_json::from_str::(&record.runtime_json).unwrap_or(Value::Null); + let payload = serde_json::from_str::(&record.payload_json).unwrap_or(Value::Null); + json!({ + "schema": "mnote.ai_run.v1", + "hostRunId": record.run_id, + "requestId": payload + .get("requestId") + .or_else(|| payload.get("request_id")) + .cloned() + .unwrap_or(Value::Null), + "agentId": payload + .get("agentId") + .or_else(|| payload.get("agent")) + .cloned() + .unwrap_or_else(|| Value::String(record.profile.clone())), + "provider": page_ai_run_provider(record), + "providerRunId": runtime + .get("runId") + .or_else(|| runtime.get("run_id")) + .cloned() + .unwrap_or_else(|| Value::String(record.run_id.clone())), + "sessionId": record.session_id, + "actorId": record.user_id, + "workspaceId": record.workspace_id, + "documentId": record.document_id, + "profile": record.profile, + "acpRuntime": record.acp_runtime, + "status": record.status, + "runtime": runtime, + "payload": payload, + "createdAt": record.created_at, + "updatedAt": record.updated_at, + "persistence": ACP_RUNTIME_SQLITE_STORE, + }) +} + +fn page_ai_journal_event_to_json(record: &control_plane::AiRuntimeJournalEventRecord) -> Value { + let event = &record.event; + let payload = serde_json::from_str::(&event.payload_json).unwrap_or(Value::Null); + json!({ + "schema": "mnote.ai_run_event.v1", + "hostRunId": event.run_id, + "seq": format_ai_run_seq(record.seq), + "kind": event.event_type, + "source": page_ai_run_event_source(event), + "eventId": event.id, + "sessionId": event.session_id, + "workspaceId": event.workspace_id, + "documentId": event.document_id, + "profile": event.profile, + "acpRuntime": event.acp_runtime, + "payload": payload, + "createdAt": event.created_at, + "persistence": ACP_RUNTIME_SQLITE_STORE, + }) +} + +fn page_ai_synthetic_terminal_event_to_json( + run: &control_plane::AiRuntimeRunRecord, + kind: &str, + seq: i64, +) -> Value { + json!({ + "schema": "mnote.ai_run_event.v1", + "hostRunId": run.run_id.clone(), + "seq": format_ai_run_seq(seq), + "kind": kind, + "source": "mnote", + "eventId": format!("synthetic:{}:{}", run.run_id, kind), + "sessionId": run.session_id.clone(), + "workspaceId": run.workspace_id.clone(), + "documentId": run.document_id.clone(), + "profile": run.profile.clone(), + "acpRuntime": run.acp_runtime.clone(), + "payload": { + "schema": "mnote.ai_run_terminal_reconciliation.v1", + "status": run.status.clone(), + "hostRunId": run.run_id.clone(), + "reason": "terminal_status_without_terminal_event" + }, + "createdAt": run.updated_at.clone(), + "persistence": ACP_RUNTIME_SQLITE_STORE, + "synthetic": true, + }) +} + +fn page_ai_run_provider(record: &control_plane::AiRuntimeRunRecord) -> &'static str { + match record.acp_runtime.as_str() { + "reasonix" => "acp_reasonix", + "chat_only" | "api-chat" => "chat_only", + _ => "hermes_client", + } +} + +fn page_ai_run_event_source(record: &control_plane::AiRuntimeEventRecord) -> &'static str { + if record.event_type.starts_with("tool.") { + return "tool"; + } + match record.acp_runtime.as_str() { + "reasonix" => "reasonix", + "chat_only" | "api-chat" => "chat_only", + _ => "hermes", + } +} + +fn page_ai_terminal_event_kind(status_or_event_type: &str) -> Option<&'static str> { + match status_or_event_type { + "completed" | "run.completed" => Some("run.completed"), + "failed" | "run.failed" => Some("run.failed"), + "cancelled" | "canceled" | "run.cancelled" | "run.canceled" => Some("run.cancelled"), + "aborted" | "run.aborted" => Some("run.aborted"), + _ => None, + } +} + +fn page_ai_run_status_is_active(status: &str) -> bool { + matches!( + status, + "pending" + | "running" + | "interrupted" + | "tool_calling" + | "queued" + | "acp_pending" + | "session.created" + ) +} + +fn parse_ai_run_seq(value: &str) -> Option { + value + .trim() + .trim_start_matches('0') + .parse::() + .ok() + .or_else(|| { + if value.trim().chars().all(|ch| ch == '0') { + Some(0) + } else { + None + } + }) +} + +fn format_ai_run_seq(seq: i64) -> String { + format!("{:018}", seq.max(0)) +} + fn query_bool(query: &HashMap, key: &str) -> bool { query .get(key) @@ -9548,11 +10435,12 @@ async fn persist_acp_runtime_event( "local_private".to_string() }); } - state + let user_id = runtime_store_user_id(context, run_payload); + let record = state .control_plane() .append_ai_runtime_event(AppendAiRuntimeEventInput { id: None, - user_id: runtime_store_user_id(context, run_payload), + user_id: user_id.clone(), workspace_id: args .get("workspaceId") .and_then(Value::as_str) @@ -9576,6 +10464,16 @@ async fn persist_acp_runtime_event( WebError::internal(format!("SQLite ACP runtime event 写入失败: {error}")) .with_context(context) })?; + let seq = state + .control_plane() + .list_ai_runtime_journal_events(&user_id, run_id, 0, 10_000) + .map_err(|error| { + WebError::internal(format!("SQLite ACP runtime event seq 读取失败: {error}")) + .with_context(context) + })? + .into_iter() + .find(|item| item.event.id == record.id) + .map(|item| format_ai_run_seq(item.seq)); persist_provider_conversation_event( state, context, @@ -9591,7 +10489,8 @@ async fn persist_acp_runtime_event( "legacyPersistence": legacy_persistence, "legacySessionStorage": legacy_session_storage, "sessionId": registration.session_id, - "runId": run_id + "runId": run_id, + "seq": seq })) } @@ -10760,6 +11659,573 @@ mod tests { let _ = fs::remove_dir_all(&hermes_home); } + #[tokio::test] + async fn page_ai_agent_descriptors_merge_manifest_capabilities_and_preferences() { + let _env_guard = env_lock().lock().expect("env lock"); + let hermes_home = std::env::temp_dir().join(format!( + "mnote-web-ai-agent-descriptors-{}", + 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 state = test_state(); + for user_id in ["user_1", "user_2"] { + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some(user_id.to_string()), + email: Some(format!("{user_id}@example.com")), + username: user_id.to_string(), + display_name: user_id.to_string(), + role: None, + password_hash: None, + }) + .expect("seed user"); + } + state + .control_plane() + .upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: None, + source_kind: None, + scope_kind: "page_ai".to_string(), + scope_id: "common".to_string(), + key: "ai.common.default_agent".to_string(), + value_json: "\"reasonix\"".to_string(), + }) + .expect("seed user 1 preference"); + state + .control_plane() + .upsert_user_ui_preference(control_plane::UpsertUserUiPreferenceInput { + id: None, + user_id: "user_2".to_string(), + workspace_id: None, + source_kind: None, + scope_kind: "page_ai".to_string(), + scope_id: "common".to_string(), + key: "ai.common.default_agent".to_string(), + value_json: "\"hermes\"".to_string(), + }) + .expect("seed user 2 preference"); + + let app = build_app(state); + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/page-ai/agents/descriptors?profile=chemist") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("descriptors 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("descriptors json"); + assert_eq!(payload["schema"], "mnote.ai_agent_descriptors.v1"); + assert_eq!(payload["profile"], "chemist"); + assert_eq!(payload["actorId"], "user_1"); + assert_eq!( + payload["preferenceValues"]["ai.common.default_agent"], + "reasonix" + ); + + let descriptors = payload["descriptors"] + .as_array() + .expect("descriptors") + .iter() + .map(|descriptor| { + ( + descriptor["agentId"] + .as_str() + .unwrap_or_default() + .to_string(), + descriptor.clone(), + ) + }) + .collect::>(); + assert!(descriptors.contains_key("hermes")); + assert!(descriptors.contains_key("reasonix")); + assert!(descriptors.contains_key("chat_only")); + + let reasonix = descriptors.get("reasonix").expect("reasonix descriptor"); + assert!(reasonix["capabilities"] + .as_array() + .expect("reasonix capabilities") + .iter() + .any(|capability| capability == "knowledge_rag")); + assert!(reasonix["tools"] + .as_array() + .expect("reasonix tools") + .iter() + .any(|tool| tool["name"] == "mnote.knowledge_rag.query")); + assert!(reasonix["capabilityPacks"] + .as_array() + .expect("reasonix capability packs") + .iter() + .any(|capability| capability["id"] == "mnote-knowledge-rag" + && capability["enabled"] == true)); + assert_eq!( + reasonix["capabilityStates"]["knowledge_rag"]["enabled"], + true + ); + + let chat_only = descriptors.get("chat_only").expect("chat-only descriptor"); + assert_eq!(chat_only["canWriteFiles"], false); + assert_eq!( + chat_only["tools"] + .as_array() + .expect("chat-only tools") + .len(), + 0 + ); + assert!(chat_only["capabilities"] + .as_array() + .expect("chat-only capabilities") + .iter() + .any(|capability| capability == "chat")); + + let toggle_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/api/hermes/client/capabilities/toggle") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from( + json!({ + "runtime": "mnote", + "profile": "chemist", + "id": "mnote-knowledge-rag", + "enabled": false + }) + .to_string(), + )) + .expect("toggle request"), + ) + .await + .expect("toggle response"); + assert_eq!(toggle_response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/page-ai/agents/descriptors?profile=chemist") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("descriptors after disable"); + 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("descriptors json"); + let reasonix = payload["descriptors"] + .as_array() + .expect("descriptors") + .iter() + .find(|descriptor| descriptor["agentId"] == "reasonix") + .expect("reasonix descriptor after disable"); + assert_eq!( + reasonix["capabilityStates"]["knowledge_rag"]["enabled"], + false + ); + assert!(reasonix["disabledCapabilities"] + .as_array() + .expect("disabled capabilities") + .iter() + .any(|capability| capability == "knowledge_rag")); + assert!(!reasonix["capabilities"] + .as_array() + .expect("reasonix capabilities") + .iter() + .any(|capability| capability == "knowledge_rag")); + let knowledge_tool = reasonix["tools"] + .as_array() + .expect("reasonix tools") + .iter() + .find(|tool| tool["name"] == "mnote.knowledge_rag.query") + .expect("knowledge query tool after disable"); + assert_eq!(knowledge_tool["enabled"], false); + assert_eq!(knowledge_tool["status"], "disabled"); + + std::env::remove_var("HERMES_HOME"); + let _ = fs::remove_dir_all(&hermes_home); + } + + #[tokio::test] + async fn page_ai_run_events_expose_journal_after_seq() { + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("user_1".to_string()), + email: Some("user_1@example.com".to_string()), + username: "user_1".to_string(), + display_name: "user_1".to_string(), + role: None, + password_hash: None, + }) + .expect("seed user"); + state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + title: Some("Page AI run".to_string()), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some("trace_1".to_string()), + status: "completed".to_string(), + runtime_json: "{\"runId\":\"provider_run_1\",\"status\":\"completed\"}".to_string(), + payload_json: + "{\"requestId\":\"request_1\",\"agentId\":\"reasonix\",\"message\":\"hello\"}" + .to_string(), + }) + .expect("seed runtime run"); + for (event_type, payload_json) in [ + ("message.delta", "{\"delta\":\"hello\"}"), + ("run.completed", "{\"output\":\"done\"}"), + ] { + state + .control_plane() + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_1".to_string(), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + event_type: event_type.to_string(), + payload_json: payload_json.to_string(), + }) + .expect("seed runtime event"); + } + + let app = build_app(state); + let run_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/page-ai/runs/run_1") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("run response"); + assert_eq!(run_response.status(), StatusCode::OK); + let body = to_bytes(run_response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("run json"); + assert_eq!(payload["schema"], "mnote.ai_run.v1"); + assert_eq!(payload["run"]["hostRunId"], "run_1"); + assert_eq!(payload["run"]["providerRunId"], "provider_run_1"); + assert_eq!(payload["run"]["status"], "completed"); + + let events_response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/page-ai/runs/run_1/events?afterSeq=000000000000000001") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("events response"); + assert_eq!(events_response.status(), StatusCode::OK); + let body = to_bytes(events_response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("events json"); + assert_eq!(payload["schema"], "mnote.ai_run_events.v1"); + assert_eq!(payload["hostRunId"], "run_1"); + assert_eq!(payload["afterSeq"], "000000000000000001"); + assert_eq!(payload["nextSeq"], "000000000000000002"); + let events = payload["events"].as_array().expect("events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["schema"], "mnote.ai_run_event.v1"); + assert_eq!(events[0]["seq"], "000000000000000002"); + assert_eq!(events[0]["kind"], "run.completed"); + assert_eq!(events[0]["source"], "reasonix"); + assert_eq!(events[0]["payload"]["output"], "done"); + } + + #[tokio::test] + async fn page_ai_run_create_is_request_id_idempotent() { + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("user_1".to_string()), + email: Some("user_1@example.com".to_string()), + username: "user_1".to_string(), + display_name: "user_1".to_string(), + role: None, + password_hash: None, + }) + .expect("seed user"); + let app = build_app(state); + let request_payload = json!({ + "requestId": "req_idempotent_1", + "agentId": "reasonix", + "profile": "reasonix", + "acpRuntime": "reasonix", + "workspaceId": "ws_1", + "documentId": "doc_1", + "sessionId": "sess_1", + "message": "hello" + }); + + let first_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-ai/runs") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from(request_payload.to_string())) + .expect("request"), + ) + .await + .expect("first response"); + assert_eq!(first_response.status(), StatusCode::OK); + let body = to_bytes(first_response.into_body(), usize::MAX) + .await + .expect("body"); + let first_payload: Value = serde_json::from_slice(&body).expect("first json"); + assert_eq!(first_payload["schema"], "mnote.ai_run_receipt.v1"); + assert_eq!(first_payload["created"], true); + assert_eq!(first_payload["idempotent"], false); + assert_eq!(first_payload["requestId"], "req_idempotent_1"); + assert_eq!(first_payload["run"]["status"], "pending"); + let host_run_id = first_payload["hostRunId"] + .as_str() + .expect("host run id") + .to_string(); + + let second_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-ai/runs") + .header("content-type", "application/json") + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::from(request_payload.to_string())) + .expect("request"), + ) + .await + .expect("second response"); + assert_eq!(second_response.status(), StatusCode::OK); + let body = to_bytes(second_response.into_body(), usize::MAX) + .await + .expect("body"); + let second_payload: Value = serde_json::from_slice(&body).expect("second json"); + assert_eq!(second_payload["created"], false); + assert_eq!(second_payload["idempotent"], true); + assert_eq!(second_payload["hostRunId"], host_run_id); + + let events_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/api/page-ai/runs/{host_run_id}/events?afterSeq=0")) + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("events response"); + assert_eq!(events_response.status(), StatusCode::OK); + let body = to_bytes(events_response.into_body(), usize::MAX) + .await + .expect("body"); + let events_payload: Value = serde_json::from_slice(&body).expect("events json"); + let events = events_payload["events"].as_array().expect("events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["seq"], "000000000000000001"); + assert_eq!(events[0]["kind"], "run.created"); + assert_eq!(events[0]["payload"]["requestId"], "req_idempotent_1"); + } + + #[tokio::test] + async fn page_ai_run_events_reconcile_missing_terminal_event() { + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("user_1".to_string()), + email: Some("user_1@example.com".to_string()), + username: "user_1".to_string(), + display_name: "user_1".to_string(), + role: None, + password_hash: None, + }) + .expect("seed user"); + state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_terminal_missing".to_string(), + title: Some("Page AI run".to_string()), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some("trace_1".to_string()), + status: "completed".to_string(), + runtime_json: "{\"status\":\"completed\"}".to_string(), + payload_json: "{\"requestId\":\"request_terminal\",\"agentId\":\"reasonix\"}" + .to_string(), + }) + .expect("seed runtime run"); + state + .control_plane() + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_terminal_missing".to_string(), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + event_type: "message.delta".to_string(), + payload_json: "{\"delta\":\"hello\"}".to_string(), + }) + .expect("seed runtime event"); + + let response = build_app(state) + .oneshot( + Request::builder() + .method("GET") + .uri( + "/api/page-ai/runs/run_terminal_missing/events?afterSeq=000000000000000001", + ) + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("events 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("events json"); + assert_eq!(payload["nextSeq"], "000000000000000002"); + let events = payload["events"].as_array().expect("events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0]["seq"], "000000000000000002"); + assert_eq!(events[0]["kind"], "run.completed"); + assert_eq!(events[0]["source"], "mnote"); + assert_eq!(events[0]["synthetic"], true); + assert_eq!( + events[0]["payload"]["reason"], + "terminal_status_without_terminal_event" + ); + } + + #[tokio::test] + async fn page_ai_session_active_run_returns_non_terminal_run() { + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("user_1".to_string()), + email: Some("user_1@example.com".to_string()), + username: "user_1".to_string(), + display_name: "user_1".to_string(), + role: None, + password_hash: None, + }) + .expect("seed user"); + for (run_id, status, request_id) in [ + ("run_completed", "completed", "request_completed"), + ("run_running", "running", "request_running"), + ] { + state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: run_id.to_string(), + title: Some("Page AI run".to_string()), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some(format!("trace_{run_id}")), + status: status.to_string(), + runtime_json: format!("{{\"status\":\"{status}\"}}"), + payload_json: format!( + "{{\"requestId\":\"{request_id}\",\"agentId\":\"reasonix\"}}" + ), + }) + .expect("seed runtime run"); + } + + let response = build_app(state) + .oneshot( + Request::builder() + .method("GET") + .uri( + "/api/page-ai/sessions/sess_1/active-run?workspaceId=ws_1&documentId=doc_1", + ) + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("active run 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("active run json"); + assert_eq!(payload["schema"], "mnote.ai_active_run.v1"); + assert_eq!(payload["sessionId"], "sess_1"); + assert_eq!(payload["active"], true); + assert_eq!(payload["run"]["hostRunId"], "run_running"); + assert_eq!(payload["run"]["status"], "running"); + assert_eq!(payload["run"]["requestId"], "request_running"); + } + #[tokio::test] async fn page_ai_capabilities_group_onlyoffice_live_bridge() { let _env_guard = env_lock().lock().expect("env lock"); @@ -11611,6 +13077,7 @@ mod tests { let run_id = payload["runId"].as_str().expect("run id").to_string(); let events_response = app + .clone() .oneshot( Request::builder() .method("GET") @@ -11631,12 +13098,39 @@ mod tests { assert!(text.contains("event: message.delta"), "{text}"); assert!(text.contains("\"delta\":\"API\""), "{text}"); assert!(text.contains("\"delta\":\" Chat\""), "{text}"); + assert!(text.contains("\"seq\":\"000000000000000001\""), "{text}"); + assert!(text.contains("\"seq\":\"000000000000000002\""), "{text}"); assert!(text.contains("event: run.completed"), "{text}"); assert_eq!( captured_body.lock().expect("captured api body")["model"], "aisz-chat/gpt-5.5-extra-high-fast" ); + let journal_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/api/page-ai/runs/{run_id}/events?afterSeq=0")) + .header("x-mnote-actor-id", "api_chat_user") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("journal request"), + ) + .await + .expect("journal response"); + assert_eq!(journal_response.status(), StatusCode::OK); + let body = to_bytes(journal_response.into_body(), usize::MAX) + .await + .expect("journal body"); + let journal: Value = serde_json::from_slice(&body).expect("journal json"); + let journal_events = journal["events"].as_array().expect("journal events"); + assert!(journal_events + .iter() + .any(|event| event["kind"] == "message.delta" && event["source"] == "chat_only")); + assert!(journal_events + .iter() + .any(|event| event["kind"] == "run.completed" && event["source"] == "chat_only")); + std::env::remove_var("MNOTE_API_CHAT_GPT_CHAT_BASE_URL"); std::env::remove_var("MNOTE_API_CHAT_GPT_CHAT_API_KEY"); } diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 606a7272..806cb6e0 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -496,6 +496,23 @@ pub fn build_router(state: AppState) -> Router { "/api/onlyoffice/bridge/capabilities", get(onlyoffice_bridge::capabilities), ) + .route( + "/api/page-ai/agents/descriptors", + get(hermes_client::list_agent_descriptors), + ) + .route("/api/page-ai/runs", post(hermes_client::create_page_ai_run)) + .route( + "/api/page-ai/runs/{host_run_id}", + get(hermes_client::get_page_ai_run), + ) + .route( + "/api/page-ai/runs/{host_run_id}/events", + get(hermes_client::list_page_ai_run_events), + ) + .route( + "/api/page-ai/sessions/{session_id}/active-run", + get(hermes_client::get_page_ai_session_active_run), + ) .route( "/api/onlyoffice/bridge/commands", post(onlyoffice_bridge::enqueue_command), diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index c2fe9efb..88c1be28 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -229,6 +229,8 @@ mod tests { include_str!("../../../browser/sidebar-workspace-runtime.js"); const SIDEBAR_PAGE_TREE_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-page-tree-runtime.js"); + // Browser Page AI runtime is served as a module; keep this include explicit so + // resume/journal contract checks inspect the actual shipped JS. const SIDEBAR_PAGE_AI_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-page-ai-runtime.js"); const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str = @@ -693,6 +695,9 @@ mod tests { SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail") ); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSearchBackendSessions")); + assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiCheckActiveRun")); + assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/page-ai/sessions/")); + assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/active-run?")); assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-rename")); assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete")); assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume")); @@ -776,6 +781,22 @@ mod tests { assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("target=\"_blank\"")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("document.addEventListener('visibilitychange'")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiCheckActiveRun(sessionId || undefined)")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiCheckAndResumeActiveRun")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiResumeActiveRunJournal")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiTrackStreamingRunEvent")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("void pageAiCheckAndResumeActiveRun().catch")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai_active_run_snapshot.v1")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-mnote-page-ai-active-run-last-seq")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/runs/")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("afterSeq=")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiCheckAndResumeActiveRun()")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/agents/descriptors")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadAgentDescriptors")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-descriptor-field")); + assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderDescriptorField")); + assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-agent-descriptor-card")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()")); assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.handlePageAi")); assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("closestAction(e.target, '[data-page-ai-action")); diff --git a/scripts/reasonix-acp-wrapper.mjs b/scripts/reasonix-acp-wrapper.mjs index 5a4b7767..d4b1f6c7 100644 --- a/scripts/reasonix-acp-wrapper.mjs +++ b/scripts/reasonix-acp-wrapper.mjs @@ -27,10 +27,12 @@ import { readFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000'; const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1'; +const REASONIX_BIN = process.env.MNOTE_REASONIX_BIN || 'reasonix'; +const REASONIX_ACP_BACKEND = (process.env.MNOTE_REASONIX_ACP_BACKEND || 'auto').trim().toLowerCase(); function debugLog(message) { if (DEBUG) process.stderr.write(`${message}\n`); @@ -74,6 +76,75 @@ function assertSameSortedSet(actual, expected, label) { } } +function parseReasonixMajorVersion(versionText) { + const text = String(versionText || ''); + const match = text.match(/\b(?:npm-v|v)?(\d+)(?:\.\d+){0,2}(?:[-+][\w.-]+)?\b/); + if (!match) return null; + const major = Number.parseInt(match[1], 10); + return Number.isFinite(major) ? major : null; +} + +function readReasonixVersionText() { + try { + return execFileSync(REASONIX_BIN, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + } catch (error) { + debugLog(`[reasonix-acp-mnote] unable to read ${REASONIX_BIN} --version: ${error instanceof Error ? error.message : String(error)}`); + return ''; + } +} + +function shouldUseNativeReasonixAcp(versionText = readReasonixVersionText()) { + if (REASONIX_ACP_BACKEND === 'legacy') return false; + if (REASONIX_ACP_BACKEND === 'native') return true; + const major = parseReasonixMajorVersion(versionText); + return major !== null && major >= 1; +} + +function nativeReasonixAcpArgs() { + const args = ['acp']; + const model = (process.env.REASONIX_MODEL || '').trim(); + if (model) args.push('-model', model); + const extraArgsText = (process.env.MNOTE_REASONIX_ACP_ARGS_JSON || '').trim(); + if (extraArgsText) { + let extraArgs; + try { + extraArgs = JSON.parse(extraArgsText); + } catch (error) { + throw new Error(`MNOTE_REASONIX_ACP_ARGS_JSON must be a JSON array of strings: ${error instanceof Error ? error.message : String(error)}`); + } + if (!Array.isArray(extraArgs) || extraArgs.some((value) => typeof value !== 'string')) { + throw new Error('MNOTE_REASONIX_ACP_ARGS_JSON must be a JSON array of strings'); + } + args.push(...extraArgs); + } + return args; +} + +function proxyNativeReasonixAcp(versionText = readReasonixVersionText()) { + const args = nativeReasonixAcpArgs(); + debugLog(`[reasonix-acp-mnote] delegating to native ${REASONIX_BIN} ${args.join(' ')} (${versionText || 'version unknown'})`); + const child = spawn(REASONIX_BIN, args, { + stdio: ['inherit', 'inherit', 'inherit'], + env: process.env, + }); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, () => { + if (!child.killed) child.kill(signal); + }); + } + child.on('error', (error) => { + process.stderr.write(`FATAL: failed to spawn native Reasonix ACP (${REASONIX_BIN}): ${error.message}\n`); + process.exit(1); + }); + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code === null ? 1 : code); + }); +} + function isWriteMnoteTool(toolName) { return ![ 'mnote.skill.read', @@ -89,6 +160,22 @@ function isWriteMnoteTool(toolName) { ].includes(toolName); } +function toolResultStatusFromContent(content) { + const text = String(content || ''); + if (!text.trim()) return 'completed'; + try { + const payload = JSON.parse(text); + if (payload && typeof payload === 'object') { + if (payload.ok === false) return 'failed'; + if (payload.error && payload.error !== null) return 'failed'; + return 'completed'; + } + } catch { + // 非 JSON 工具结果按普通文本处理,只识别明确错误前缀。 + } + return /^\s*(error|failed|exception)\b/i.test(text) ? 'failed' : 'completed'; +} + function stableIdPart(value, fallback) { const text = String(value || fallback || '') .trim() @@ -181,6 +268,15 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) { } if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') { + if (parseReasonixMajorVersion('reasonix 0.53.2') !== 0) { + throw new Error('selftest expected legacy Reasonix 0.x version parsing'); + } + if (parseReasonixMajorVersion('reasonix npm-v1.4.0-rc.1') !== 1) { + throw new Error('selftest expected native Reasonix 1.x version parsing'); + } + if (parseReasonixMajorVersion('unversioned') !== null) { + throw new Error('selftest expected unversioned Reasonix output to be unknown'); + } const payload = buildMnoteToolPayload( 'mnote.context.read_current_page', { @@ -331,6 +427,14 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') { rustKnowledgeTools, 'selftest expected Reasonix wrapper tool mapping to match Rust knowledge RAG manifest tools', ); + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const knowledgeSkillPath = join(scriptDir, '..', 'skills', 'mnote-knowledge-rag', 'SKILL.md'); + const knowledgeSkillText = readFileSync(knowledgeSkillPath, 'utf8'); + for (const toolName of rustKnowledgeTools) { + if (!knowledgeSkillText.includes(toolName)) { + throw new Error(`selftest expected mnote-knowledge-rag skill text to mention ${toolName}`); + } + } process.stderr.write('[reasonix-acp-mnote] selftest ok\n'); process.exit(0); } @@ -369,6 +473,11 @@ if (!DEEPSEEK_API_KEY) { process.env.DEEPSEEK_API_KEY = DEEPSEEK_API_KEY; +const reasonixVersionText = readReasonixVersionText(); +if (shouldUseNativeReasonixAcp(reasonixVersionText)) { + proxyNativeReasonixAcp(reasonixVersionText); +} else { + // ── Dynamic import of reasonix public API ──────────── let CacheFirstLoop, DeepSeekClient, ToolRegistry, ImmutablePrefix; @@ -533,22 +642,6 @@ function emitToolResult(sessionId, toolCallId, status, text) { }); } -function toolResultStatusFromContent(content) { - const text = String(content || ''); - if (!text.trim()) return 'completed'; - try { - const payload = JSON.parse(text); - if (payload && typeof payload === 'object') { - if (payload.ok === false) return 'failed'; - if (payload.error && payload.error !== null) return 'failed'; - return 'completed'; - } - } catch { - // 非 JSON 工具结果按普通文本处理,只识别明确错误前缀。 - } - return /^\s*(error|failed|exception)\b/i.test(text) ? 'failed' : 'completed'; -} - function emitUsage(sessionId, used, size) { emitSessionUpdate(sessionId, { sessionUpdate: 'usage_update', @@ -1129,3 +1222,4 @@ for (const raw of queuedRpcLines.splice(0)) { await handleRpcLine(raw); } process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`); +} diff --git a/scripts/task529-knowledge-rag-citation-resource-tab-smoke.js b/scripts/task529-knowledge-rag-citation-resource-tab-smoke.js index 1c8f3f23..a98922c5 100644 --- a/scripts/task529-knowledge-rag-citation-resource-tab-smoke.js +++ b/scripts/task529-knowledge-rag-citation-resource-tab-smoke.js @@ -19,6 +19,47 @@ const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"] .find((candidate) => fs.existsSync(candidate)); +async function assertKnowledgeRagDescriptor(requestContext) { + const response = await requestContext.get(`${BASE_URL}/api/page-ai/agents/descriptors?profile=task529-knowledge-rag`); + assert(response.ok(), `descriptor API 失败: ${response.status()} ${await response.text()}`); + const payload = await response.json(); + const reasonix = (payload.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix"); + assert(reasonix, "descriptor 缺少 Reasonix"); + assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled"); + assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag"); + const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name)); + for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) { + assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`); + } +} + +async function status(requestContext) { + const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); + const response = await requestContext.get(`${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); + assert(response.ok(), `knowledge-rag status 失败: ${response.status()} ${await response.text()}`); + return await response.json(); +} + +async function ensureIndexed(requestContext) { + const ingestResponse = await requestContext.post(`${BASE_URL}/api/knowledge-rag/ingest`, { + data: { + rootUri: ROOT_URI, + workspaceId: WORKSPACE_ID, + sources: [{ sourcePath: EXPECTED_RESOURCE }], + }, + }); + assert(ingestResponse.ok(), `knowledge-rag ingest 失败: ${ingestResponse.status()} ${await ingestResponse.text()}`); + const startedAt = Date.now(); + let lastEntry = null; + while (Date.now() - startedAt < 90_000) { + const payload = await status(requestContext); + lastEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === EXPECTED_RESOURCE) || null; + if (lastEntry?.indexedAtMs && lastEntry?.lightRagDocId && !lastEntry?.stale && !lastEntry?.deletedAtMs) return lastEntry; + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Error(`等待目标资料入库超时: ${JSON.stringify(lastEntry, null, 2)}`); +} + async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true, executablePath: CHROMIUM_EXECUTABLE_PATH }); @@ -38,6 +79,8 @@ async function main() { }, }); assert(auth.ok(), `登录失败: ${auth.status()} ${await auth.text()}`); + await assertKnowledgeRagDescriptor(context.request); + await ensureIndexed(context.request); const queryResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, { data: { diff --git a/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js b/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js index 121ec553..41b403d8 100644 --- a/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js +++ b/scripts/task530-knowledge-rag-page-ai-final-answer-smoke.js @@ -22,6 +22,7 @@ const WORKSPACE_ID = "local-ws:mnote-e2e:my-space"; const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; const ROOT_URI = `file://${ROOT_PATH}`; const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md"; +const EXPECTED_RESOURCE = "新页面233155/image copy 6.png"; const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`; function sqlQuote(value) { @@ -79,6 +80,75 @@ async function signIn(context) { assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); } +async function assertKnowledgeRagDescriptor(context) { + const response = await context.request.get(`${BASE_URL}/api/page-ai/agents/descriptors?profile=reasonix`, { + headers: { + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "user", + accept: "application/json", + }, + }); + assert(response.ok(), `descriptor API 失败: ${response.status()} ${await response.text()}`); + const payload = await response.json(); + const reasonix = (payload.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix"); + assert(reasonix, "descriptor 缺少 Reasonix"); + assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled"); + assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag"); + const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name)); + for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) { + assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`); + } +} + +async function knowledgeRagStatus(context) { + const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); + const response = await context.request.get(`${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); + assert(response.ok(), `knowledge-rag status 失败: ${response.status()} ${await response.text()}`); + return await response.json(); +} + +async function ensureKnowledgeRagSourceIndexed(context) { + const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/ingest`, { + data: { + rootUri: ROOT_URI, + workspaceId: WORKSPACE_ID, + sources: [{ sourcePath: EXPECTED_RESOURCE }], + }, + }); + assert(response.ok(), `knowledge-rag ingest 失败: ${response.status()} ${await response.text()}`); + const startedAt = Date.now(); + let lastEntry = null; + while (Date.now() - startedAt < 90_000) { + const payload = await knowledgeRagStatus(context); + lastEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === EXPECTED_RESOURCE) || null; + if (lastEntry?.indexedAtMs && lastEntry?.lightRagDocId && !lastEntry?.stale && !lastEntry?.deletedAtMs) return lastEntry; + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Error(`等待目标资料入库超时: ${JSON.stringify(lastEntry, null, 2)}`); +} + +async function assertKnowledgeRagQueryReturnsCitationMarkdown(context) { + const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, { + data: { + rootUri: ROOT_URI, + workspaceId: WORKSPACE_ID, + query: "这个图片资料在资料库里是什么内容?", + mode: "mix", + topK: 8, + chunkTopK: 8, + includeChunkContent: true, + sourcePaths: [EXPECTED_RESOURCE], + }, + }); + assert(response.ok(), `knowledge-rag query 失败: ${response.status()} ${await response.text()}`); + const payload = await response.json(); + const references = Array.isArray(payload.references) ? payload.references : []; + assert( + references.some((reference) => String(reference.citationMarkdown || "").trim() && String(reference.citationUrl || "").includes("resourceTab=")), + `query 输出缺少 citationMarkdown,Page AI 最终回答 smoke 不能继续: ${JSON.stringify(references, null, 2).slice(0, 3000)}`, + ); +} + async function selectReasonix(page) { await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS }); await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); @@ -219,6 +289,9 @@ async function main() { try { await signIn(context); + await assertKnowledgeRagDescriptor(context); + await ensureKnowledgeRagSourceIndexed(context); + await assertKnowledgeRagQueryReturnsCitationMarkdown(context); const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`); documentUrl.searchParams.set("sourceKind", "local_folder"); documentUrl.searchParams.set("rootUri", ROOT_URI); @@ -232,7 +305,7 @@ async function main() { .count(); const prompt = [ "请调用 mnote_knowledge_rag_query 检索资料库。", - "问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 [\"新页面233155/image copy 6.png\"]。", + `问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 ["${EXPECTED_RESOURCE}"]。`, "最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接;如果 locatorDegraded=true,必须说明来源定位降级,不要描述检索过程,不要输出 raw JSON,不要编造页码或 bbox。", ].join("\n"); await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS }); diff --git a/scripts/task538-knowledge-rag-source-scope-api-smoke.js b/scripts/task538-knowledge-rag-source-scope-api-smoke.js index 0d9272b3..525035fc 100644 --- a/scripts/task538-knowledge-rag-source-scope-api-smoke.js +++ b/scripts/task538-knowledge-rag-source-scope-api-smoke.js @@ -60,6 +60,19 @@ async function signIn(context) { assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`); } +async function assertKnowledgeRagDescriptor(context) { + const result = await apiJson(context, "GET", `${BASE_URL}/api/page-ai/agents/descriptors?profile=task538-knowledge-rag`); + assert(result.ok, `descriptor API 失败: ${result.status} ${result.text.slice(0, 1000)}`); + const reasonix = (result.payload?.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix"); + assert(reasonix, "descriptor 缺少 Reasonix"); + assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled"); + assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag"); + const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name)); + for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) { + assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`); + } +} + async function status(context) { const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID }); const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`); @@ -149,6 +162,7 @@ async function main() { const context = await request.newContext({ baseURL: BASE_URL }); try { await signIn(context); + await assertKnowledgeRagDescriptor(context); await ingest(context, [sourceA, sourceB]); const indexedStatus = await waitForIndexed(context, [sourceA, sourceB]); const scoped = await query(context, marker, [sourceA]); diff --git a/scripts/task557-page-ai-agent-descriptor-smoke.js b/scripts/task557-page-ai-agent-descriptor-smoke.js new file mode 100644 index 00000000..703a29cd --- /dev/null +++ b/scripts/task557-page-ai-agent-descriptor-smoke.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const { request } = require("playwright"); + +const TASK = "task557-page-ai-agent-descriptor-smoke"; +const BASE_URL = ( + process.env.MNOTE_UI_BASE_URL || + process.env.MNOTE_WEB_SMOKE_BASE_URL || + "http://127.0.0.1:3000" +).replace(/\/+$/, ""); +const ACTOR_ID = process.env.MNOTE_PAGE_AI_DESCRIPTOR_ACTOR_ID || `task557-descriptor-${Date.now().toString(36)}`; +const PROFILE = process.env.MNOTE_PAGE_AI_DESCRIPTOR_PROFILE || "task557-descriptor-smoke"; +const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); + +function descriptorById(payload) { + return new Map( + (payload.descriptors || []).map((descriptor) => [ + descriptor.agentId, + descriptor, + ]), + ); +} + +function arrayIncludes(value, expected, message) { + assert(Array.isArray(value), `${message}: 不是数组`); + assert(value.includes(expected), `${message}: 缺少 ${expected}`); +} + +function toolsByName(descriptor) { + return new Map( + (descriptor.tools || []).map((tool) => [ + tool.name, + tool, + ]), + ); +} + +async function loadDescriptors(api) { + const response = await api.get(`/api/page-ai/agents/descriptors?profile=${encodeURIComponent(PROFILE)}`, { + timeout: REQUEST_TIMEOUT_MS, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch (error) { + throw new Error(`descriptor response 不是 JSON: ${text.slice(0, 1000)}`); + } + assert(response.ok(), `descriptor API 失败: ${response.status()} ${text.slice(0, 1000)}`); + return payload; +} + +async function main() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + const api = await request.newContext({ + baseURL: BASE_URL, + extraHTTPHeaders: { + "x-mnote-actor-id": ACTOR_ID, + "x-mnote-actor-type": "user", + accept: "application/json", + }, + }); + + try { + const payload = await loadDescriptors(api); + assert.equal(payload.schema, "mnote.ai_agent_descriptors.v1", "顶层 schema 不正确"); + assert.equal(payload.profile, PROFILE, "profile 应回显 query profile"); + assert.equal(payload.actorId, ACTOR_ID, "actorId 应使用请求 actor"); + assert(Array.isArray(payload.descriptors), "descriptors 必须是数组"); + assert.equal(payload.descriptors.length, 3, "应返回 Hermes / Reasonix / Chat-only 三类 agent"); + + const descriptors = descriptorById(payload); + for (const agentId of ["hermes", "reasonix", "chat_only"]) { + assert(descriptors.has(agentId), `缺少 ${agentId} descriptor`); + assert.equal(descriptors.get(agentId).schema, "mnote.ai_agent_descriptor.v1", `${agentId} descriptor schema 不正确`); + } + + const hermes = descriptors.get("hermes"); + assert.equal(hermes.provider, "hermes_client", "Hermes provider 不正确"); + assert.equal(hermes.canWriteFiles, true, "Hermes 应允许文件写入能力声明"); + arrayIncludes(hermes.capabilities, "mnote_tools", "Hermes capabilities"); + assert(toolsByName(hermes).has("mnote.knowledge_rag.query"), "Hermes tools 应包含 knowledge_rag.query"); + + const reasonix = descriptors.get("reasonix"); + assert.equal(reasonix.provider, "acp_reasonix", "Reasonix provider 不正确"); + assert.equal(reasonix.acpRuntime, "reasonix", "Reasonix acpRuntime 不正确"); + assert.equal(reasonix.canWriteFiles, true, "Reasonix 应允许文件写入能力声明"); + arrayIncludes(reasonix.capabilities, "native_patch", "Reasonix capabilities"); + arrayIncludes(reasonix.capabilities, "knowledge_rag", "Reasonix capabilities"); + const reasonixTools = toolsByName(reasonix); + for (const toolName of [ + "mnote.knowledge_rag.status", + "mnote.knowledge_rag.query", + "mnote.knowledge_rag.open_reference", + ]) { + assert(reasonixTools.has(toolName), `Reasonix tools 缺少 ${toolName}`); + assert.equal(reasonixTools.get(toolName).enabled, true, `${toolName} 默认应启用`); + } + assert( + (reasonix.capabilityPacks || []).some((capability) => capability.id === "mnote-knowledge-rag" && capability.enabled === true), + "Reasonix capabilityPacks 应包含启用的 mnote-knowledge-rag", + ); + assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix capabilityStates 应声明 knowledge_rag 默认启用"); + + const chatOnly = descriptors.get("chat_only"); + assert.equal(chatOnly.provider, "chat_only", "Chat-only provider 不正确"); + assert.equal(chatOnly.canWriteFiles, false, "Chat-only 不应声明文件写入"); + assert.equal((chatOnly.tools || []).length, 0, "Chat-only 不应暴露 MNote tools"); + arrayIncludes(chatOnly.capabilities, "chat", "Chat-only capabilities"); + assert.deepEqual(chatOnly.defaultContextRefs || [], [], "Chat-only 默认不应携带 MNote context refs"); + + const toggleResponse = await api.put("/api/hermes/client/capabilities/toggle", { + timeout: REQUEST_TIMEOUT_MS, + headers: { "content-type": "application/json" }, + data: { + runtime: "mnote", + profile: PROFILE, + id: "mnote-knowledge-rag", + enabled: false, + }, + }); + const toggleText = await toggleResponse.text(); + assert(toggleResponse.ok(), `禁用 knowledge rag capability 失败: ${toggleResponse.status()} ${toggleText.slice(0, 1000)}`); + const disabledPayload = await loadDescriptors(api); + const disabledReasonix = descriptorById(disabledPayload).get("reasonix"); + assert.equal(disabledReasonix.capabilityStates?.knowledge_rag?.enabled, false, "禁用后 descriptor capabilityStates 应为 false"); + assert(!(disabledReasonix.capabilities || []).includes("knowledge_rag"), "禁用后 Reasonix capabilities 不应继续声明 knowledge_rag"); + assert((disabledReasonix.disabledCapabilities || []).includes("knowledge_rag"), "禁用后 disabledCapabilities 应包含 knowledge_rag"); + assert.equal(toolsByName(disabledReasonix).get("mnote.knowledge_rag.query")?.enabled, false, "禁用后 knowledge_rag.query tool 应不可用"); + + const result = { + ok: true, + task: TASK, + baseUrl: BASE_URL, + profile: PROFILE, + actorId: ACTOR_ID, + agents: payload.descriptors.map((descriptor) => ({ + agentId: descriptor.agentId, + provider: descriptor.provider, + canWriteFiles: descriptor.canWriteFiles, + capabilityCount: (descriptor.capabilities || []).length, + toolCount: (descriptor.tools || []).length, + })), + disabledKnowledgeRagVerified: true, + }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } finally { + await api.dispose().catch(() => undefined); + } +} + +main().catch((error) => { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync( + path.join(OUTPUT_DIR, "failure.json"), + `${JSON.stringify({ ok: false, task: TASK, error: error.stack || error.message || String(error) }, null, 2)}\n`, + "utf8", + ); + console.error(error.stack || error.message || String(error)); + process.exit(1); +}); diff --git a/scripts/task557-page-ai-run-resume-smoke.js b/scripts/task557-page-ai-run-resume-smoke.js new file mode 100644 index 00000000..fcfb6945 --- /dev/null +++ b/scripts/task557-page-ai-run-resume-smoke.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { chromium } = require("playwright"); + +const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote"; +const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers")); +const TASK = "task557-page-ai-run-resume-smoke"; +const OUTPUT_DIR = path.join(ROOT, "tmp", TASK); +const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); +const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-run-resume.png"); +const CONTROL_PLANE_DB = + process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; +const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; +const RESUME_TIMEOUT_MS = Number(process.env.MNOTE_PAGE_AI_RESUME_TIMEOUT_MS || 90_000); +const ACTOR_ID = "mnote-e2e"; +const WORKSPACE_ID = "local-ws:mnote-e2e:my-space"; +const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space"; +const ROOT_URI = `file://${ROOT_PATH}`; +const OWNER_REL = "knowledge-rag-fixtures-7-57/PageAiRunResumeSmoke.md"; +const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`; + +function sqlQuote(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function sqliteExec(sql) { + execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function ensureFixture() { + assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`); + const ownerPath = path.join(ROOT_PATH, OWNER_REL); + fs.mkdirSync(path.dirname(ownerPath), { recursive: true }); + fs.writeFileSync(ownerPath, "# Page AI Run Resume Smoke\n\n用于验证 Page AI host run journal afterSeq 恢复。\n", "utf8"); + const now = new Date().toISOString(); + sqliteExec(` + INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision) + VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision) + VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision) + VALUES ('grant_task557_page_ai_run_resume', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + `); +} + +function seedRun() { + const stamp = Date.now(); + const runId = `run_task557_resume_${stamp}`; + const sessionId = `mnote_task557_resume_${stamp}`; + const now = new Date().toISOString(); + const runtimeJson = JSON.stringify({ runId, status: "running", lastEvent: "message.delta" }); + const payloadJson = JSON.stringify({ + requestId: `task557_resume_${stamp}`, + agentId: "reasonix", + message: "resume smoke user prompt", + }); + sqliteExec(` + INSERT OR REPLACE INTO ai_runtime_runs + (id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision) + VALUES + (${sqlQuote(`arr_${stamp}`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'Resume smoke', 'reasonix', 'reasonix', ${sqlQuote(`trace_task557_${stamp}`)}, 'running', ${sqlQuote(runtimeJson)}, ${sqlQuote(payloadJson)}, NULL, ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + DELETE FROM ai_runtime_events WHERE user_id=${sqlQuote(ACTOR_ID)} AND run_id=${sqlQuote(runId)}; + INSERT INTO ai_runtime_events + (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at) + VALUES + (${sqlQuote(`are_${stamp}_1`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', '{"delta":"old-token"}', ${sqlQuote(now)}), + (${sqlQuote(`are_${stamp}_2`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', '{"delta":"resumed-token"}', ${sqlQuote(now)}); + `); + return { runId, sessionId }; +} + +async function signIn(context) { + const response = await context.request.post(`${BASE_URL}/api/auth`, { + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { + account: ACTOR_ID, + password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", + flow: "signIn", + }, + }, + }, + timeout: UI_TIMEOUT_MS, + }); + assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${await response.text()}`); +} + +async function main() { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + ensureFixture(); + const seeded = seedRun(); + const browser = await chromium.launch({ + headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1", + executablePath: fs.existsSync(CHROME) ? CHROME : undefined, + }); + const context = await browser.newContext({ viewport: { width: 1360, height: 900 } }); + const page = await context.newPage(); + const pageErrors = []; + const consoleErrors = []; + const apiRequests = []; + const apiResponses = []; + page.on("pageerror", (error) => pageErrors.push(String(error?.message || error))); + page.on("console", (message) => { + if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`); + }); + page.on("request", (request) => { + if (/\/api\/(page-ai|hermes\/client)/.test(request.url())) { + apiRequests.push({ method: request.method(), url: request.url() }); + } + }); + page.on("response", (response) => { + if (/\/api\/(page-ai|hermes\/client)/.test(response.url())) { + apiResponses.push({ status: response.status(), url: response.url() }); + } + }); + try { + await signIn(context); + await page.addInitScript(({ documentId, runId, sessionId }) => { + window.localStorage.setItem(`hermes_page_ai_session:${documentId}:active-run`, JSON.stringify({ + schema: "mnote.page_ai_active_run_snapshot.v1", + hostRunId: runId, + sessionId, + lastSeq: "000000000000000001", + status: "running", + createdAt: Date.now(), + updatedAt: Date.now(), + })); + }, { documentId: DOCUMENT_ID, runId: seeded.runId, sessionId: seeded.sessionId }); + + const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`); + documentUrl.searchParams.set("sourceKind", "local_folder"); + documentUrl.searchParams.set("rootUri", ROOT_URI); + documentUrl.searchParams.set("workspaceId", WORKSPACE_ID); + await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + try { + await page.waitForFunction( + (runId) => document.documentElement.getAttribute("data-mnote-page-ai-run-journal-resumed") === runId, + seeded.runId, + { timeout: RESUME_TIMEOUT_MS }, + ); + await page.waitForFunction( + () => document.documentElement.getAttribute("data-mnote-page-ai-active-run-last-seq") === "000000000000000002", + null, + { timeout: RESUME_TIMEOUT_MS }, + ); + } catch (error) { + const debugState = await page.evaluate(() => { + const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); + return { + attrs: Object.fromEntries(Array.from(document.documentElement.attributes).map((attr) => [attr.name, attr.value])), + drawerHidden: drawer ? drawer.hidden : null, + drawerText: drawer ? (drawer.textContent || "").slice(0, 1000) : "", + storageKeys: Object.keys(window.localStorage || {}).filter((key) => key.includes("page_ai") || key.includes("hermes_page_ai")), + }; + }).catch((stateError) => ({ stateError: String(stateError) })); + debugState.apiRequests = apiRequests; + debugState.apiResponses = apiResponses; + debugState.pageErrors = pageErrors; + debugState.consoleErrors = consoleErrors; + await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined); + fs.writeFileSync(path.join(OUTPUT_DIR, "failure-state.json"), `${JSON.stringify(debugState, null, 2)}\n`, "utf8"); + throw error; + } + const state = await page.evaluate((runId) => { + const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); + const text = drawer ? drawer.textContent || "" : ""; + return { + resumedRunId: document.documentElement.getAttribute("data-mnote-page-ai-run-journal-resumed") || "", + activeHostRunId: document.documentElement.getAttribute("data-mnote-page-ai-active-host-run-id") || "", + lastSeq: document.documentElement.getAttribute("data-mnote-page-ai-active-run-last-seq") || "", + hasResumedToken: text.includes("resumed-token"), + hasSkippedToken: text.includes("old-token"), + runStatus: document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "", + drawerText: text.slice(0, 500), + expectedRunId: runId, + }; + }, seeded.runId); + await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); + assert.equal(state.resumedRunId, seeded.runId, `未标记 journal resumed: ${JSON.stringify(state, null, 2)}`); + assert.equal(state.activeHostRunId, seeded.runId, `active host run id 不匹配: ${JSON.stringify(state, null, 2)}`); + assert.equal(state.lastSeq, "000000000000000002", `lastSeq 未推进: ${JSON.stringify(state, null, 2)}`); + assert.equal(state.hasResumedToken, true, `未渲染 afterSeq 后的新事件: ${JSON.stringify(state, null, 2)}`); + assert.equal(state.hasSkippedToken, false, `重复渲染了 afterSeq 之前的事件: ${JSON.stringify(state, null, 2)}`); + assert.deepEqual(pageErrors, [], `页面异常: ${pageErrors.join("\n")}`); + const result = { + ok: true, + task: TASK, + baseUrl: BASE_URL, + documentId: DOCUMENT_ID, + runId: seeded.runId, + sessionId: seeded.sessionId, + state, + consoleErrors, + apiRequests, + apiResponses, + screenshot: SCREENSHOT_PATH, + }; + fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } finally { + await browser.close().catch(() => undefined); + } +} + +main().catch((error) => { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + fs.writeFileSync(path.join(OUTPUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, task: TASK, error: error.stack || error.message || String(error) }, null, 2)}\n`, "utf8"); + console.error(error.stack || error.message || String(error)); + process.exit(1); +});