feat(page-ai): add resumable run journal descriptors

This commit is contained in:
lix-2026
2026-06-09 18:48:46 +08:00
parent 6ef233772e
commit 922965d30f
17 changed files with 3254 additions and 58 deletions
@@ -0,0 +1,466 @@
# 7-57 [done] Yuxi reference: AI run journal, config schema and knowledge facade v1
> 创建时间:2026-06-09
>
> 当前状态:`DONE`
>
> Owner07-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 reconciliationSSE 断开时查 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 idPage AI UI、audit、event journal 和 browser resume 使用。
- `providerRunId`Hermes / Reasonix / Chat-only 自己的 run/session id,作为外部关联字段。
- `requestId`:用户一次点击发送生成的幂等键,浏览器重试时不重复创建 run。
## 4. Design AAI 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 已经有自己的 SSERust 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 BAI 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.<agentId>` 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 disabledPage AI 不引导 agent 调用该工具。
- 前端删除硬编码工具 enable 列表,只保留渲染逻辑和兼容 fallback。
## 6. Design CKnowledge 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 Bhost 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 cursorACP/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 DKnowledge 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 deltaSQLite 可能膨胀。第一版应 compact tool/result payload,保留 UI 恢复必需事件,不保存大段 provider raw chunks。
- 如果 descriptor 同时由前端和后端维护,会形成新漂移。必须以后端 route 为准,前端只渲染。
- 如果 `hostRunId` 与 provider run id 混用,审计会断链。所有 UI、audit、changed files 都使用 hostRunIdprovider id 只作为关联字段。
- 如果把 Yuxi 的 `line/window open` 直接套到 PDF/DOCX/image,会破坏 MNote locator 模型。Knowledge facade 必须继续以 `open_reference -> citationUrl` 为中心。
+6
View File
@@ -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<EntityId>,
+110 -10
View File
@@ -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<AiRuntim
})
}
fn row_to_ai_runtime_journal_event(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<AiRuntimeJournalEventRecord> {
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<AiExternalConversationBindingRecord> {
@@ -2355,6 +2376,24 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(rows)
}
fn find_ai_runtime_run(
&self,
user_id: &str,
run_id: &str,
) -> Result<Option<AiRuntimeRunRecord>, 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<Vec<AiRuntimeJournalEventRecord>, 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::<Result<Vec<_>, _>>()
.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]
+23 -9
View File
@@ -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<Vec<AiRuntimeRunRecord>, ControlPlaneError>;
fn find_ai_runtime_run(
&self,
user_id: &str,
run_id: &str,
) -> Result<Option<AiRuntimeRunRecord>, ControlPlaneError>;
fn append_ai_runtime_event(
&self,
input: AppendAiRuntimeEventInput,
@@ -220,6 +226,14 @@ pub trait ControlPlaneStore: Send + Sync {
limit: usize,
) -> Result<Vec<AiRuntimeEventRecord>, ControlPlaneError>;
fn list_ai_runtime_journal_events(
&self,
user_id: &str,
run_id: &str,
after_seq: i64,
limit: usize,
) -> Result<Vec<AiRuntimeJournalEventRecord>, ControlPlaneError>;
fn upsert_ai_external_conversation_binding(
&self,
input: UpsertAiExternalConversationBindingInput,
@@ -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);
@@ -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) {
'</button>';
}
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 = '<select data-page-ai-descriptor-field="' + escapeHtml(key) + '" data-page-ai-descriptor-kind="select">' +
options.map(function(option) {
return '<option value="' + escapeHtml(option.value) + '"' + (String(value) === String(option.value) ? ' selected' : '') + '>' + escapeHtml(option.label) + '</option>';
}).join('') +
'</select>';
} else if (kind === 'boolean' || kind === 'toggle') {
control = '<input type="checkbox" data-page-ai-descriptor-field="' + escapeHtml(key) + '" data-page-ai-descriptor-kind="boolean"' + (value === true ? ' checked' : '') + ' />';
} else {
control = '<input type="text" value="' + escapeHtml(value == null ? '' : String(value)) + '" data-page-ai-descriptor-field="' + escapeHtml(key) + '" data-page-ai-descriptor-kind="' + escapeHtml(kind || 'string') + '" />';
}
return '' +
'<label class="wolai-page-ai-profile-select" data-page-ai-descriptor-field-row="' + escapeHtml(key) + '">' +
'<span>' + escapeHtml(label) + '</span>' +
control +
'</label>';
}
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 ? '<div class="wolai-page-ai-tool-meta">disabled: ' + escapeHtml(disabled.join(', ')) + '</div>' : '';
return '' +
'<section class="wolai-page-ai-memory-card" data-page-ai-agent-descriptor-card="' + escapeHtml(agentId) + '">' +
'<div class="wolai-page-ai-memory-head">' +
'<div><div class="wolai-page-ai-memory-title">' + escapeHtml(title) + '</div><div class="wolai-page-ai-memory-scope">' + escapeHtml(pageAiAgentDescriptorDetail(descriptor) || 'descriptor fallback') + '</div></div>' +
'</div>' +
disabledText +
(fields ? '<div class="wolai-page-ai-settings-grid">' + fields + '</div>' : '<div class="wolai-page-ai-tool-meta">当前 agent 没有可配置字段。</div>') +
'</section>';
}
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 = '' +
'<div class="wolai-page-ai-context-popover-head">' +
'<strong>选择 Agent</strong>' +
@@ -294,8 +355,8 @@ export function createSidebarPageAiRenderRuntime(context) {
'<div class="wolai-page-ai-agent-popover-title">Reasonix</div>' +
'<div class="wolai-page-ai-agent-option-list">' +
'<button type="button" class="wolai-page-ai-agent-option' + (reasonixActive ? ' is-active' : '') + '" data-page-ai-agent-id="reasonix" aria-pressed="' + (reasonixActive ? 'true' : 'false') + '">' +
'<span class="wolai-page-ai-agent-option-label">Reasonix</span>' +
'<span class="wolai-page-ai-agent-option-detail">可读取并在授权目录内写文件</span>' +
'<span class="wolai-page-ai-agent-option-label">' + escapeHtml(reasonixLabel) + '</span>' +
'<span class="wolai-page-ai-agent-option-detail">' + escapeHtml(reasonixDetail) + '</span>' +
'</button>' +
'</div>' +
'</div>';
@@ -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 ? '<div class="wolai-page-ai-inline-error">' + escapeHtml(pageUiState.pageAiAgentDescriptorError) + '</div>' : '') +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">授权区域</div><div class="wolai-page-ai-memory-scope">' + escapeHtml(pageAiBuildAllowedRoots().length ? 'SQLite directory_grants' : '需要授权') + '</div></div>' +
'</section>' +
pageAiRenderAgentDescriptorCard(activeAgentId, pageAiCurrentAgentSelectionLabel()) +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">默认上下文</div><div class="wolai-page-ai-memory-scope">ContextRefs</div></div>' +
'</section>' +
@@ -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') +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head">' +
'<div><div class="wolai-page-ai-memory-title">Reasonix 专属设置</div><div class="wolai-page-ai-memory-scope">ACP runtime / memory</div></div>' +
@@ -723,7 +787,8 @@ export function createSidebarPageAiRenderRuntime(context) {
}
var chatOnlyPanel = drawer.querySelector('[data-page-ai-chat-only-panel]');
if (chatOnlyPanel instanceof HTMLElement) {
chatOnlyPanel.innerHTML = '<section class="wolai-page-ai-memory-card"><div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">Chat-only</div><div class="wolai-page-ai-memory-scope">默认不申请文件写权限</div></div></section>';
chatOnlyPanel.innerHTML = pageAiRenderAgentDescriptorCard('chat_only', 'Chat-only') +
'<section class="wolai-page-ai-memory-card"><div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">Chat-only</div><div class="wolai-page-ai-memory-scope">默认不申请文件写权限</div></div></section>';
}
}
@@ -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();
});
});
}
@@ -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
};
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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),
@@ -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"));
+111 -17
View File
@@ -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`);
}
@@ -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: {
@@ -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 输出缺少 citationMarkdownPage 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 });
@@ -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]);
@@ -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);
});
+221
View File
@@ -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);
});