chore: checkpoint pi lab rust integration work
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
# 3-28 dev:hot seed smoke 启动契约缺失
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 已修复
|
||||||
|
- 日期:2026-07-11
|
||||||
|
- Owner:`03-rust-web`
|
||||||
|
|
||||||
|
## 症状
|
||||||
|
|
||||||
|
依赖 `scripts/lib/control-plane-dev-seed.js` 的 browser smoke 在当前 `dev:hot` 服务上调用 `/api/dev/seed` 时返回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
403 dev_seed_disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本在创建测试 workspace / AI policy 前终止,导致真实 UI 交互未执行。由于 cargo-watch 只重启 Rust 子进程,临时修改启动环境但不重启 Node `dev:hot` 主进程也不会生效。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
- `dev:hot` 没有建立“默认可运行 seed 依赖 smoke”的启动契约。
|
||||||
|
- `MNOTE_WEB_ALLOW_DEV_FIXTURES` 只能由操作者临时记忆和手工补充。
|
||||||
|
- seed helper 只返回通用 403,未明确指出应重启哪个启动链。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- `npm run dev:hot` 默认设置 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1`。
|
||||||
|
- 显式 `MNOTE_WEB_ALLOW_DEV_FIXTURES=0 npm run dev:hot` 仍可关闭。
|
||||||
|
- `desktop:hot` 与生产启动保持默认关闭,不扩大正式运行边界。
|
||||||
|
- dev seed helper 对 `dev_seed_disabled` 输出明确的启动与重启指令。
|
||||||
|
- Pi RPC API smoke 不再把 `dev_seed_disabled` 静默标记为 skipped,而是明确失败并要求恢复正确启动契约。
|
||||||
|
- 完整 RPC smoke 暴露了 abort 后迟到 `agent_end` 将 `aborted` 覆盖为 `runtime_running` 的竞态;runtime 现在保持 `aborted/error` 终态,迟到空回复事件也不会覆盖已中止状态。
|
||||||
|
- RPC smoke 按当前 status 合同验收:已中止会话退出 auto-resume `status.session`,并通过 `/api/page-ai/pi/sessions/{sessionId}` 验证持久化状态为 `aborted`。
|
||||||
|
- `scripts/TESTING_REFERENCE.md` 增加 seed 依赖 smoke 的统一启动契约。
|
||||||
|
- `task-dev-hot-plan-test.js` 固化默认开启和显式关闭行为。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `node scripts/task-dev-hot-plan-test.js`
|
||||||
|
- 重启 `npm run dev:hot`
|
||||||
|
- `node scripts/task-pi-lab-input-controls-smoke.js`
|
||||||
|
- `node scripts/task-pi-lab-model-thinking-controls-smoke.js`
|
||||||
|
- `node scripts/task-pi-lab-rpc-api-smoke.js`
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 7-59 Page AI Pi OmniRoute 兼容修复过度降级
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 已修复
|
||||||
|
- 日期:2026-07-11
|
||||||
|
- Owner:`07-ai`
|
||||||
|
|
||||||
|
## 症状
|
||||||
|
|
||||||
|
为处理 Pi Rust 经 OmniRoute 偶发空回复,MNote 曾在 session `models.json` 中设置:
|
||||||
|
|
||||||
|
- `supportsTools=false`
|
||||||
|
- `supportsUsageInStreaming=false`
|
||||||
|
- 将请求改经 MNote 内置代理并删除 `stream_options`
|
||||||
|
|
||||||
|
普通文本回复恢复后,Pi 的原生 function calling 实际被关闭,MNote bridge、LightRAG、MCP 和扩展工具无法由模型调用。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
`pi_agent_rust 0.1.18` 在 `supportsTools=false` 时会完全省略 OpenAI 请求的 `tools` 字段,不存在文本工具调用 fallback。
|
||||||
|
|
||||||
|
进一步实测表明:
|
||||||
|
|
||||||
|
- Pi Rust 直连当前 OmniRoute 时,`stream_options.include_usage=false` 和 `true` 均能完成真实工具调用。
|
||||||
|
- OmniRoute `freefirst` 的少量空流是上游路由偶发现象,未与 `stream_options` 建立因果关系。
|
||||||
|
- 真实 Pi RPC 在 `supportsTools=true` 下可调用 `mnote_allowed_roots_describe`,收到 `pi-rust-native-context-file` 工具结果并完成第二轮回答。
|
||||||
|
|
||||||
|
本次空回复修复中真正需要保留的是:
|
||||||
|
|
||||||
|
- `apiKey` 使用裸环境变量名 `OPENAI_API_KEY`
|
||||||
|
- 不再把 `MNOTE_PI_CONTEXT_V1` 前缀注入模型可见 prompt
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- OmniRoute provider 恢复 `supportsTools=true`
|
||||||
|
- 恢复 `supportsUsageInStreaming=true`
|
||||||
|
- Pi Rust 恢复直连配置的 OmniRoute base URL
|
||||||
|
- 删除只为移除 `stream_options` 引入的 MNote 中转路由
|
||||||
|
- 增加 models 配置单测和静态 smoke 断言
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `cargo test -p mnote-web page_ai_pi --lib`:24 passed
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `node scripts/task-pi-lab-static-smoke.js`:228 checks passed
|
||||||
|
- 真实 Chromium + 3000:
|
||||||
|
- `supportsTools=true`
|
||||||
|
- `supportsUsageInStreaming=true`
|
||||||
|
- 工具调用:`mnote_allowed_roots_describe`
|
||||||
|
- 工具结果:`pi-rust-native-context-file`
|
||||||
|
- 最终回复:`PI_TOOLS_RESTORED_OK_1783711633924`
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 7-60 Page AI Pi 模型与思考深度弹层锚定修复
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 已修复
|
||||||
|
- 日期:2026-07-11
|
||||||
|
- Owner:`07-ai`
|
||||||
|
|
||||||
|
## 症状
|
||||||
|
|
||||||
|
Page AI Pi 底部工具栏中:
|
||||||
|
|
||||||
|
- 先点击 `+` 后再打开模型或思考深度,原生下拉通常出现在按钮附近。
|
||||||
|
- 直接点击模型或思考深度,或先点击页面其它位置再打开时,Chromium 原生下拉可能出现在页面中央。
|
||||||
|
- `+` 与访问控制使用按钮锚定自定义菜单,模型与思考深度仍使用原生 `<select>`,交互与关闭规则不一致。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
模型与思考深度的选项面板由浏览器原生 `<select>` popup 承载,其位置不受 Page AI composer 的定位上下文和菜单互斥状态控制。焦点、滚动容器和点击顺序变化时,浏览器可能使用独立 popup 坐标,导致弹层脱离按钮。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 将模型与思考深度改为 `button + position: relative wrapper + absolute menu`。
|
||||||
|
- 菜单统一向对应按钮上方展开,不再依赖 Chromium 原生 select popup。
|
||||||
|
- 模型、思考深度、`+` 和访问控制统一使用互斥菜单状态。
|
||||||
|
- 点击工具栏其它位置、面板外或关闭 Page AI 时统一关闭 composer 菜单。
|
||||||
|
- 模型与思考深度选项继续写入原有 Pi session 配置状态,并沿 `/api/page-ai/pi/configure` 应用到 runtime。
|
||||||
|
- 增加 `aria-expanded`、`menuitemradio` 和选中状态,保留键盘/辅助技术可识别的控制语义。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js`
|
||||||
|
- Chromium 真实页面验证:
|
||||||
|
- 模型菜单位于模型按钮正上方。
|
||||||
|
- 思考深度菜单位于思考按钮正上方。
|
||||||
|
- 模型、思考深度、`+` 菜单互斥。
|
||||||
|
- 点击输入框后关闭,再次打开仍锚定按钮。
|
||||||
|
- 选择“思考 关”后标签与 runtime 状态同步。
|
||||||
|
- 专项静态与浏览器 smoke 见对应测试脚本结果。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 7-61 Page AI Pi 历史回放真源混用
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
Pi Lab 实时对话正常,但重新打开历史 session 后,同一条 assistant 结果可能重复显示多次;工具过程没有同步重复。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
历史打开链路同时读取并合并两个来源:
|
||||||
|
|
||||||
|
- `/api/page-ai/pi/sessions/{id}/tree`:Pi Rust JSONL session tree,会话消息真源。
|
||||||
|
- `/api/page-ai/pi/sessions/{id}/events`:control-plane runtime events,用于 SSE backlog、导出和诊断。
|
||||||
|
|
||||||
|
`events` 中同一次回复可能以 `message_update/text_delta`、`message_end`、`agent_end` 等形式记录运行过程;它不是历史消息 UI 的 canonical source。前端把 `tree` 和 `events` 合并回放后,就会把同一 assistant 结果渲染成多条消息。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- `sidebar-page-ai-pi-lab-runtime.js`
|
||||||
|
- `openHistorySession()` 只从 `/tree` 读取并回放消息。
|
||||||
|
- 移除历史回放消息层面的 `eventsToReplayMessages()` / `mergeReplayMessages()` / `replayMessageKey()`,避免用去重掩盖多真源。
|
||||||
|
- `/events` 继续保留给实时 SSE backlog、导出和诊断,不参与历史消息回放。
|
||||||
|
- `scripts/task-pi-lab-history-tail-smoke.js`
|
||||||
|
- 构造 JSONL 只有 1 条 assistant 回复,但 runtime events 中包含多段同文结果事件。
|
||||||
|
- 打开历史后断言 UI 只显示 JSONL 中的 1 条 assistant 回复。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js`
|
||||||
|
- `node --check scripts/task-pi-lab-history-tail-smoke.js`
|
||||||
|
- `node scripts/task-pi-lab-static-smoke.js`
|
||||||
|
- `node scripts/task-pi-lab-history-tail-smoke.js`
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
> - `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs`
|
> - `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md`
|
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-21-local-folder-convex-unified-tree-source-v1.md`
|
||||||
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
|
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
|
||||||
|
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
|
||||||
|
|
||||||
## 1. 结论
|
## 1. 结论
|
||||||
|
|
||||||
@@ -64,6 +65,17 @@
|
|||||||
|
|
||||||
- 不把 tree/domain、Convex 或 editor island 的所有语义一起重写。
|
- 不把 tree/domain、Convex 或 editor island 的所有语义一起重写。
|
||||||
|
|
||||||
|
### 4.5 本地文件 AI 工具接入(关联 7-14)
|
||||||
|
|
||||||
|
> 按 7-14,本地 `.md` 文件应通过 `mnote.doc.fetch(format: "markdown")` 供 AI 读取,通过 `mnote.doc.markdown_edit` 供 AI 写入。本方案的 GFM AST → markdown 序列化层是 AI 写入本地文件的关键依赖。
|
||||||
|
|
||||||
|
- AI 读取:`mnote.doc.fetch` 的 `source` 检测到本地文件路径时,直接返回 `.md` 原文(不经 AST 解析)。
|
||||||
|
- AI 写入:`mnote.doc.markdown_edit` 对本地文件执行 markdown 级搜索替换后,通过本方案的 markdown 序列化层写回文件系统。
|
||||||
|
- 不要求本地文件维护 blockId。AI 编辑基于文本,不基于块结构。
|
||||||
|
- 本地文件的 AI 写入适配器(`LocalFSAdapter`)复用 `local_folder_source.rs` 的写链路。
|
||||||
|
|
||||||
|
本接入点不改变本方案的解析/序列化核心设计,仅在其上增加 AI 工具消费层。
|
||||||
|
|
||||||
## 5. 方案比较
|
## 5. 方案比较
|
||||||
|
|
||||||
### 方案 A:继续扩写手写 parser
|
### 方案 A:继续扩写手写 parser
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# 7-74 Page AI Pi RPC Parity 与产品化补全 Checklist v1
|
||||||
|
|
||||||
|
状态:done
|
||||||
|
Owner:07-ai / mnote-web / browser runtime / control-plane
|
||||||
|
日期:2026-07-10
|
||||||
|
|
||||||
|
## 1. 结论
|
||||||
|
|
||||||
|
当前 MNote Pi Lab 的架构边界保持不变:MNote Rust Web 托管 `pi_agent_rust` RPC 子进程,MNote 负责 `AiAccessScope`、allowed roots、bridge tools、receipt、MCP、LightRAG 和 control-plane 审计;不迁移到 `pi-web` 的 Next.js / in-process AgentSession 架构。
|
||||||
|
|
||||||
|
`pi-web` 与官方 `pi_agent_rust` 的参考价值主要在产品化和 RPC 命令面:
|
||||||
|
|
||||||
|
- 补齐官方 RPC 命令:`get_state`、`get_messages`、`compact`、`fork`、`set_steering_mode`、`set_follow_up_mode`、`set_auto_compaction`。
|
||||||
|
- 用官方 session tree / JSONL entry 模型支撑历史 replay、entry 级 fork、compaction 摘要展示。
|
||||||
|
- 完善 composer:draft 持久化、`@` 文件补全、运行中 steer/follow-up 队列状态。
|
||||||
|
- 完善 artifacts:file patch split diff、citation/open-reference、MCP raw、receipt audit 统一入口。
|
||||||
|
|
||||||
|
本稿是 `7-69 Page AI Pi-first Lab` 与 `7-72 Page AI Pi UI 补全方案` 之后的执行稿,目标是把 Pi Lab 从“可用实验面板”推进到“能长期作为 MNote-native Page AI runtime 候选”的最小闭环。
|
||||||
|
|
||||||
|
## 2. 对照证据
|
||||||
|
|
||||||
|
### 2.1 pi-web
|
||||||
|
|
||||||
|
本轮只读拉取:
|
||||||
|
|
||||||
|
- 仓库:`https://github.com/agegr/pi-web`
|
||||||
|
- 本地路径:`/tmp/pi-web-analysis`
|
||||||
|
- commit:`82f9442`
|
||||||
|
|
||||||
|
可参考文件:
|
||||||
|
|
||||||
|
- `/tmp/pi-web-analysis/lib/session-reader.ts`:读取 Pi JSONL,构建 UI messages、entryIds、compaction、branch summary。
|
||||||
|
- `/tmp/pi-web-analysis/components/BranchNavigator.tsx`:会话分支树、active path、压缩线性链。
|
||||||
|
- `/tmp/pi-web-analysis/components/ChatInput.tsx`:draft、slash commands、`@` 文件补全、运行中 steer/follow-up。
|
||||||
|
- `/tmp/pi-web-analysis/lib/patch.ts`:unified patch 解析为 split diff rows。
|
||||||
|
- `/tmp/pi-web-analysis/components/FileViewer.tsx`:文件预览、diff、图片/音频/PDF/DOCX 入口。
|
||||||
|
- `/tmp/pi-web-analysis/lib/worktree.ts`:Git worktree 分组与切换,暂只作为未来 MNote workspace 参考。
|
||||||
|
|
||||||
|
不迁移:
|
||||||
|
|
||||||
|
- Next API routes 与 `globalThis` hot reload cache。
|
||||||
|
- `~/.pi/agent/sessions` 作为 MNote 默认事实源。
|
||||||
|
- in-process `@earendil-works/pi-coding-agent` 生命周期。
|
||||||
|
- Pi 原生 `bash/read/write/edit` 默认开放策略。
|
||||||
|
|
||||||
|
### 2.2 pi_agent_rust
|
||||||
|
|
||||||
|
本轮只读核对:
|
||||||
|
|
||||||
|
- 仓库:`https://github.com/Dicklesworthstone/pi_agent_rust`
|
||||||
|
- 本地路径:`/tmp/pi-agent-rust-official-20260710`
|
||||||
|
- commit:`43fddc06`
|
||||||
|
- 时间:2026-07-09
|
||||||
|
|
||||||
|
关键证据:
|
||||||
|
|
||||||
|
- `/tmp/pi-agent-rust-official-20260710/src/rpc.rs`
|
||||||
|
- `prompt` 支持 `streamingBehavior=steer/followUp/follow_up`。
|
||||||
|
- `get_state` 返回 model、thinking、context usage、pending queue、auto compaction/retry。
|
||||||
|
- `compact` 会生成 compaction entry 并替换 agent context。
|
||||||
|
- `fork` 能从 `entryId` 生成新 session。
|
||||||
|
- `set_steering_mode` / `set_follow_up_mode` / `set_auto_compaction` 是官方 RPC 命令;queue mode 只接受 `one-at-a-time` / `all`。
|
||||||
|
- `/tmp/pi-agent-rust-official-20260710/docs/rpc.md`
|
||||||
|
- 明确 `extension_ui_request` / `extension_ui_response`、`agent_end`、`tool_execution_*`、`auto_compaction_*` 等事件。
|
||||||
|
- `/tmp/pi-agent-rust-official-20260710/docs/session.md`
|
||||||
|
- Session JSONL entry 类型包括 `message`、`model_change`、`thinking_level_change`、`compaction`、`branch_summary`、`custom`。
|
||||||
|
|
||||||
|
## 3. 当前 MNote 状态
|
||||||
|
|
||||||
|
当前已存在能力:
|
||||||
|
|
||||||
|
- `rust/crates/mnote-web/src/routes/page_ai_pi.rs`
|
||||||
|
- `status/start/send/configure/abort/events/tool-call/tool-call-bridge/ui-response/sessions/session-events`。
|
||||||
|
- session owner 校验、bridge token、allowed roots、tool receipt、MCP sync bridge、permission mode。
|
||||||
|
- `send` 已透传 `streamingBehavior`。
|
||||||
|
- `configure` 已在当前工作区脏改动中接入 `set_model` / `set_thinking_level`。
|
||||||
|
- `rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js`
|
||||||
|
- `PiRunViewModel`、tool timeline、history drawer、queue badge、Artifacts/Receipts、extension UI、model/thinking controls。
|
||||||
|
- 已有 `pendingQueue` reducer 入口,但需要后端 `get_state`/官方 queue state 兜底同步。
|
||||||
|
- `packages/pi-mnote/extensions/mnote-bridge.ts`
|
||||||
|
- 当前脏改动已把 `mnote.local_file.read/patch` 尽量转为 Pi Rust native fs + context file 路径。
|
||||||
|
- `scripts/task-pi-lab-*`
|
||||||
|
- 已有 static/API/UI/real skill MCP/input controls 等 smoke 基线。
|
||||||
|
|
||||||
|
主要缺口:
|
||||||
|
|
||||||
|
- 缺少 MNote API 层对官方 `get_state`、`compact`、`fork(entryId)`、queue mode、auto compaction 的稳定封装。
|
||||||
|
- 历史 replay 主要依赖 MNote runtime events,还没有完整读取 Pi JSONL entry tree。
|
||||||
|
- Composer 没有本地 draft 持久化和 `@` 文件补全。
|
||||||
|
- Artifact diff 仍偏摘要,缺少 split diff 预览与 open-file 定位。
|
||||||
|
|
||||||
|
## 4. 架构边界
|
||||||
|
|
||||||
|
### 4.1 MNote 继续拥有权限
|
||||||
|
|
||||||
|
Pi 运行时只能通过 MNote 托管的安全面访问 MNote 数据:
|
||||||
|
|
||||||
|
- `mnote.current_page.read`
|
||||||
|
- `mnote.selection.read`
|
||||||
|
- `mnote.allowed_roots.describe`
|
||||||
|
- `mnote.local_file.read`
|
||||||
|
- `mnote.local_file.patch`
|
||||||
|
- `mnote.knowledge_rag.*`
|
||||||
|
- `mnote.reference.open`
|
||||||
|
- `mnote.tool_receipt.write`
|
||||||
|
|
||||||
|
原生 Pi `read/write/edit/bash/hashline_edit` 只有在 MNote 明确启用 permission-system 或 debug/admin 模式时才允许。普通用户路径不得绕过 `directory_grants` 与 `AiAccessScope`。
|
||||||
|
|
||||||
|
### 4.2 Pi session 是 runtime log,不是正文真相
|
||||||
|
|
||||||
|
Pi JSONL 可以作为:
|
||||||
|
|
||||||
|
- history replay source
|
||||||
|
- fork/branch source
|
||||||
|
- debugging/export source
|
||||||
|
- runtime recovery source
|
||||||
|
|
||||||
|
但不能成为:
|
||||||
|
|
||||||
|
- MNote 页面正文真相
|
||||||
|
- local-folder source truth
|
||||||
|
- allowed roots 真相
|
||||||
|
- tool audit 主存储
|
||||||
|
|
||||||
|
MNote control-plane 的 `ai_runtime_runs`、`ai_runtime_events`、`ai_tool_events`、`ai_file_patches` 仍是 MNote 侧可查询审计和历史入口。
|
||||||
|
|
||||||
|
### 4.3 不新增轮询
|
||||||
|
|
||||||
|
前端主路径不新增 `setInterval` 或周期轮询。状态同步优先:
|
||||||
|
|
||||||
|
- Pi RPC event -> MNote SSE
|
||||||
|
- 显式 command response
|
||||||
|
- `get_state` 只在打开面板、发送前后、SSE reconnect、abort/compact/configure 后定点调用
|
||||||
|
- 页面/文件变化走 MNote watcher / realtime
|
||||||
|
|
||||||
|
## 5. 分阶段 Checklist
|
||||||
|
|
||||||
|
### Phase A:RPC parity 最小闭环
|
||||||
|
|
||||||
|
- [x] A1. 新增 `/api/page-ai/pi/state`,封装官方 `get_state`。
|
||||||
|
- 输入:`sessionId`
|
||||||
|
- 输出:`running/isStreaming/isCompacting/model/thinkingLevel/contextUsage/pendingMessageCount/queuedMessages/autoCompactionEnabled/autoRetryEnabled`
|
||||||
|
- 约束:mock runtime 返回稳定假数据;未启动返回明确错误。
|
||||||
|
- 当前真实 runtime 只发送 `get_state` one-way RPC,并返回 MNote session 推导状态;真实 Pi response correlation 进入 A7。
|
||||||
|
- [x] A2. 前端在 drawer open、start、send ack、abort、SSE reconnect 后定点刷新 state。
|
||||||
|
- 不使用周期轮询。
|
||||||
|
- 将 `queuedMessages` 同步到 `PiRunViewModel.pendingQueue`。
|
||||||
|
- [x] A3. 新增 `/api/page-ai/pi/compact`,封装官方 `compact`。
|
||||||
|
- 支持 `customInstructions/reserveTokens/keepRecentTokens`。
|
||||||
|
- 返回 compaction summary、firstKeptEntryId、tokensBefore、details。
|
||||||
|
- 持久化 `runtime_compacted` event。
|
||||||
|
- [x] A4. 前端增加 compact 按钮与 compaction result 卡片。
|
||||||
|
- streaming 中禁用。
|
||||||
|
- 失败时显示明确 error,不写入正文。
|
||||||
|
- [x] A5. 新增 queue mode / auto compaction 配置薄 API。
|
||||||
|
- `/api/page-ai/pi/queue-config`
|
||||||
|
- 封装 `set_steering_mode`、`set_follow_up_mode`、`set_auto_compaction`。
|
||||||
|
- mode 值域按官方合同限制为 `one-at-a-time` / `all`,兼容 `oneAtATime` / `one_at_a_time` 输入。
|
||||||
|
- 第一版 UI 可仅放在折叠设置区。
|
||||||
|
- [x] A6. smoke 覆盖 state/compact/queue-config 路由存在、禁用态稳定、runtime asset 无轮询。
|
||||||
|
- [x] A7. 增加 Pi RPC request-response correlation。
|
||||||
|
- 为 `get_state`、`compact`、`queue-config` 追踪 RPC `id` 与 stdout response。
|
||||||
|
- 将真实 `contextUsage/queuedMessages/autoCompactionEnabled/autoRetryEnabled/compaction result` 映射到 MNote schema。
|
||||||
|
- timeout 时返回 `rpcResponsePending=true` 或明确 degraded reason,不伪装成完整真实状态。
|
||||||
|
|
||||||
|
### Phase B:Session tree / history replay
|
||||||
|
|
||||||
|
- [x] B1. 记录并返回 `pi_session_file`。
|
||||||
|
- start 后从 Pi RPC response、session dir 扫描或 runtime event 中确定。
|
||||||
|
- 写入 `ai_runtime_runs`。
|
||||||
|
- [x] B2. 新增受控读取 Pi JSONL 的内部 helper。
|
||||||
|
- 限制只能读取当前 session 自己的 `pi_session_file`。
|
||||||
|
- 单行大小、总字节数、entry 数量有上限。
|
||||||
|
- 不读任意路径。
|
||||||
|
- [x] B3. 新增 `/api/page-ai/pi/sessions/{id}/tree`。
|
||||||
|
- 输出 entry tree、active leaf、compaction/branch summary、message preview。
|
||||||
|
- [x] B4. 前端 history replay 优先使用 session tree。
|
||||||
|
- 保留 control-plane events 作为 fallback。
|
||||||
|
- 显示 compaction summary 和 branch summary。
|
||||||
|
- [x] B5. 新增 `/api/page-ai/pi/fork`。
|
||||||
|
- 输入:`sessionId`、可选 `entryId`。
|
||||||
|
- 优先调用官方 `fork`。
|
||||||
|
- 输出新 session/run 绑定。
|
||||||
|
- [x] B6. history UI 支持 entry 级 “从这里继续”。
|
||||||
|
|
||||||
|
### Phase C:Composer 可用性
|
||||||
|
|
||||||
|
- [x] C1. 输入草稿持久化。
|
||||||
|
- key:`pi-lab:${workspaceId}:${rootUri}:${pagePath}:${sessionId || new}`。
|
||||||
|
- 保存 textarea、context selection、model/thinking 临时选择。
|
||||||
|
- 发送成功后清理当前 draft。
|
||||||
|
- [x] C2. `@` 文件补全。
|
||||||
|
- 数据源:MNote allowed roots / FileTree projection。
|
||||||
|
- 不扫描未授权目录。
|
||||||
|
- 插入格式优先 `@relative/path`,发送时进入 `contextRefs`。
|
||||||
|
- [x] C3. composer 中显示已引用文件 chips。
|
||||||
|
- 可删除。
|
||||||
|
- 发送时与 `selectedContext` 一起写入 Pi context file。
|
||||||
|
- [x] C4. streaming 中 steer/follow-up 的队列状态与 state API 对齐。
|
||||||
|
- abort 后保留未发送文本与 queue summary。
|
||||||
|
|
||||||
|
### Phase D:Artifacts / Diff / Citation
|
||||||
|
|
||||||
|
- [x] D1. file patch receipt 生成统一 artifact。
|
||||||
|
- `rootUri/relativePath/beforeFileVersion/afterFileVersion/diffSummary/toolEventId`
|
||||||
|
- [x] D2. 后端提供受控 diff 预览。
|
||||||
|
- 优先从 `ai_file_patches.patch_summary_json` 或 receipt payload 取。
|
||||||
|
- 不允许直接读未授权路径。
|
||||||
|
- [x] D3. 前端 split diff viewer。
|
||||||
|
- 借鉴 `pi-web/lib/patch.ts`,但落成 MNote 原生 JS helper。
|
||||||
|
- 支持折叠 unchanged lines。
|
||||||
|
- [x] D4. citation/open-reference artifact 与 LightRAG `open_reference` 对齐。
|
||||||
|
- 引用打开失败时显示 stale/deleted/permission denied。
|
||||||
|
|
||||||
|
### Phase E:验证与归档
|
||||||
|
|
||||||
|
- [x] E1. 静态 smoke:`node scripts/task-pi-lab-static-smoke.js`
|
||||||
|
- [x] E2. API smoke:`node scripts/task-pi-lab-api-endpoint-smoke.js`
|
||||||
|
- [x] E3. UI smoke:复用或扩展 `scripts/task-pi-lab-ui-completion-smoke.js`
|
||||||
|
- [x] E4. Rust 单元:`cargo test -p mnote-web page_ai_pi --lib`
|
||||||
|
- [x] E5. 若触及 control-plane schema/helper,补对应 control-plane 测试。
|
||||||
|
- 本轮未改 control-plane schema/helper;相关验证由 API / browser smoke 覆盖。
|
||||||
|
- [x] E6. 浏览器验证:
|
||||||
|
- desktop viewport:`node scripts/task-pi-lab-browser-smoke.js`
|
||||||
|
- mobile viewport:沿既有 7-72 Phase 1 mobile smoke 证据保留;本轮没有新增移动专属布局改动。
|
||||||
|
- real Pi runtime:`node scripts/task-pi-lab-browser-smoke.js`
|
||||||
|
- disabled/missing runtime:`node scripts/task-pi-lab-api-endpoint-smoke.js` 覆盖 disabled may 401/404 稳定返回。
|
||||||
|
- 补充:`dev:hot` 已默认启用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1`;重启 Node 主进程后,`node scripts/task-pi-lab-input-controls-smoke.js` 完整通过,证据:`/tmp/mnote-pi-input-controls-1783715878475/result.json`。
|
||||||
|
- [x] E7. 完成后运行 `codegraph sync . && codegraph status .`。
|
||||||
|
- [x] E8. 全部完成后把本稿移动到 `design/07-ai/done/`,并在 `7-72` 中补一行后续完成链接。
|
||||||
|
|
||||||
|
## 6. 第一批执行范围
|
||||||
|
|
||||||
|
第一批已完成 Phase A-D 的产品化闭环:
|
||||||
|
|
||||||
|
1. 后端新增 `state`、`compact`、`queue-config` API,并补齐 RPC request-response correlation。
|
||||||
|
2. 后端新增 session tree、fork、artifact diff 受控 API。
|
||||||
|
3. 前端接入 state 定点同步、compact 控件、history tree replay、entry fork、draft、`@` 引用、split diff、artifact/citation 打开。
|
||||||
|
4. smoke 增加路由、runtime 字符串、UI completion 和真实浏览器断言。
|
||||||
|
|
||||||
|
暂不做:
|
||||||
|
|
||||||
|
- worktree 管理。
|
||||||
|
- review session / streaming apply 的 Phase C 体验。
|
||||||
|
|
||||||
|
本轮已把官方 RPC state/queue/compaction、session tree/fork、composer 引用和 artifact diff 变成 MNote 可观察合同。
|
||||||
|
|
||||||
|
## 7. 风险
|
||||||
|
|
||||||
|
- 当前工作区已有 Pi Lab 相关未提交改动,后续实现必须基于现状增量修改,不得回滚。
|
||||||
|
- 官方 `pi_agent_rust` RPC event/response 仍可能变化;MNote API 应只暴露稳定的 MNote schema。
|
||||||
|
- 真实 runtime 的 `state` / `compact` 已有 request-response correlation;若官方 schema 变化,MNote 仍应返回 `rpcResponsePending=true` / degraded reason,而不是伪装完整真实状态。
|
||||||
|
- `compact` 会改变 Pi session context,但不应改变 MNote 页面正文。
|
||||||
|
- JSONL 读取必须严格限定 session 所属路径,否则会变成任意文件读取面。
|
||||||
|
- 前端不得为了补状态引入周期轮询。
|
||||||
@@ -376,3 +376,7 @@ Pi Lab 主体分四层:
|
|||||||
- Regression:
|
- Regression:
|
||||||
- `/api/ai-admin/settings` 删除 Skill/MCP 后 effective 不再出现残留
|
- `/api/ai-admin/settings` 删除 Skill/MCP 后 effective 不再出现残留
|
||||||
- admin/user AI policy 不能越权启用全局未授权 skill/mcp/model/root
|
- admin/user AI policy 不能越权启用全局未授权 skill/mcp/model/root
|
||||||
|
|
||||||
|
## 9. 后续完成记录
|
||||||
|
|
||||||
|
- 2026-07-10:7-74 已完成 RPC parity、session tree/fork、composer draft/@ 引用、artifact diff/citation 产品化补全,归档到 `design/07-ai/done/7-74-page-ai-pi-rpc-parity-and-productization-checklist-v1.md`。
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
|
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
|
||||||
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
|
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
|
||||||
> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
|
> - `/mnt/Data1T/mnote/design/07-ai/done/7-6-mnote-hermes-plugin-tool-contract-v1.md`
|
||||||
|
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
|
||||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main`
|
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main`
|
||||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/blocknote-ai`
|
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/blocknote-ai`
|
||||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-apcore`
|
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-apcore`
|
||||||
@@ -52,6 +53,8 @@
|
|||||||
|
|
||||||
> **Hermes 继续作为唯一页面 AI agent runtime;mnote 提供 Agent-native editor command layer。**
|
> **Hermes 继续作为唯一页面 AI agent runtime;mnote 提供 Agent-native editor command layer。**
|
||||||
|
|
||||||
|
**新增(2026-05-16,按 7-14)**:AI 编辑的主路径应降维到 markdown 文本层。`mnote.doc.markdown_edit`(搜索替换文本对)是 AI 写入的主入口,覆盖 80%+ 场景;`mnote.block.*` 保留为结构性辅助。在线 Convex 文档和本地 `.md` 文件通过 `mnote.doc.fetch(format: "markdown")` + `mnote.doc.markdown_edit` 共用同一条 AI 写入路径。
|
||||||
|
|
||||||
因此,`本地意图解析 + Rust apply` 必须被重新定义为:
|
因此,`本地意图解析 + Rust apply` 必须被重新定义为:
|
||||||
|
|
||||||
- Hermes 的工具路由提示层。
|
- Hermes 的工具路由提示层。
|
||||||
@@ -72,21 +75,13 @@
|
|||||||
|
|
||||||
### 2.1 `/api/page-ai/block-edit-workflow` 方向需要收口
|
### 2.1 `/api/page-ai/block-edit-workflow` 方向需要收口
|
||||||
|
|
||||||
当前 route 已证明低歧义中文块编辑可以很快完成:
|
**当前口径修正(2026-05-16,按 7-14)**:
|
||||||
|
|
||||||
```text
|
- `direct_block_edit_operations`(正则抠「」内文本的快路径)应退役。这不是 AI,是命令行。
|
||||||
local_rule -> mnote.doc.apply_block_ops -> Rust apply -> page readback
|
- `/api/page-ai/block-edit-workflow` 底层应切换到 `mnote.doc.markdown_edit`:用户自然语言 → 模型产出 search/replace 文本对 → markdown_edit apply。
|
||||||
```
|
- 不再维持 direct path / model fallback 双路径,markdown_edit 是唯一主路径。
|
||||||
|
|
||||||
但如果把这个 route 继续扩成 `PageAIIntentParser / OperationPlanner / ApplyController`,它会自然变成第二套 runtime:
|
当前 route 对低歧义中文的加速效果不应成为保留一条非 AI 路径的理由。快路径作为 deterministic shortcut 的定位不变,但其实现必须改为调用 `mnote.doc.markdown_edit`,而不是绕过模型直接拼 operations。
|
||||||
|
|
||||||
- 自己判断意图。
|
|
||||||
- 自己调用模型。
|
|
||||||
- 自己解析模型输出。
|
|
||||||
- 自己决定 fallback。
|
|
||||||
- 自己写入并展示结果。
|
|
||||||
|
|
||||||
这会和 Hermes 的 session、profile、tool toggle、tool event、usage、audit、abort/retry 产生重叠。
|
|
||||||
|
|
||||||
### 2.2 模型直接输出 operations 仍不可靠
|
### 2.2 模型直接输出 operations 仍不可靠
|
||||||
|
|
||||||
@@ -278,6 +273,8 @@ Browser Page AI panel
|
|||||||
|
|
||||||
替代当前继续扩大的 `block-edit-workflow` 概念。
|
替代当前继续扩大的 `block-edit-workflow` 概念。
|
||||||
|
|
||||||
|
**新增(2026-05-16,按 7-14)**:Router 的 `recommendedToolCall` 主输出改为 `mnote.doc.markdown_edit`(search/replace 文本对),块级 `mnote.doc.apply_block_ops` 仅在明确的结构性编辑场景(拖拽排序等)下推荐。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
|
|
||||||
- 用户 prompt。
|
- 用户 prompt。
|
||||||
@@ -290,28 +287,28 @@ Browser Page AI panel
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schema": "mnote.page_ai_command_route.v1",
|
"schema": "mnote.page_ai_command_route.v1",
|
||||||
"intent": "direct_block_edit",
|
"intent": "markdown_edit",
|
||||||
"confidence": 0.94,
|
"confidence": 0.94,
|
||||||
"recommendedToolCall": {
|
"recommendedToolCall": {
|
||||||
"toolName": "mnote.doc.apply_block_ops",
|
"toolName": "mnote.doc.markdown_edit",
|
||||||
"args": {
|
"args": {
|
||||||
"operations": [
|
"operations": [
|
||||||
{"op": "replace", "matchText": "A", "content": "B"}
|
{"search": "原文片段", "replace": "新文本"}
|
||||||
],
|
]
|
||||||
"dryRun": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"risk": "low",
|
"risk": "low",
|
||||||
"requiresHermesRun": false,
|
"requiresHermesRun": false,
|
||||||
"requiresReview": false,
|
"requiresReview": false,
|
||||||
"reason": "明确中文引号替换表达,目标文本唯一命中"
|
"reason": "明确文本替换表达,目标文本唯一命中"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
|
|
||||||
- 只覆盖低歧义命令。
|
- 默认推荐 `mnote.doc.markdown_edit`(search/replace 文本对,AI 不需要理解 blockId)。
|
||||||
- 不能为复杂改写、总结、跨页面、多块结构化编辑直接生成写入。
|
- 仅在明确的结构性编辑场景("把第三块拖到第一块后面")推荐 `mnote.doc.apply_block_ops`。
|
||||||
|
- 不能为复杂改写、总结、跨页面直接生成写入。
|
||||||
- 不能调用第二套长链模型;如需模型,交给 Hermes run。
|
- 不能调用第二套长链模型;如需模型,交给 Hermes run。
|
||||||
- 输出必须可被 Hermes 当作 tool hint 消费。
|
- 输出必须可被 Hermes 当作 tool hint 消费。
|
||||||
|
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
[2026-04-26T23:16:13Z] [SESSION-0] RESET Cleared previous completed tree rust family harness batch; new batch is rebuilt from /mnt/Data1T/mnote/design/04-tree-domain/process/4-16-tree-rust-family-remaining-final-runtime-checklist-v1.md.
|
|
||||||
[2026-04-26T23:16:13Z] [SESSION-0] INIT Initialized 9 tasks for Phase K runtime artifact/state machines, Phase L tree.subtree.move write-path sinking, Phase M formal block/save events, Phase N route thinning, and Phase O search hardening.
|
|
||||||
[2026-04-26T23:16:13Z] [SESSION-0] INIT Base commit 6d89f5ca; unrelated dirty files must be preserved.
|
|
||||||
[2026-04-26T23:16:13Z] [SESSION-1] LOCK acquired (pid=manual-codex)
|
|
||||||
[2026-04-26T23:16:13Z] [SESSION-1] Starting [task-001] Phase K-1:定义 tree shell Rust runtime artifact 边界 (base=6d89f5ca)
|
|
||||||
[2026-04-26T23:16:13Z] [SESSION-1] STATS tasks_total=9 completed=0 failed=0 pending=8 in_progress=1 blocked=0 attempts_total=1 checkpoints=0
|
|
||||||
[2026-04-26T23:22:39Z] [SESSION-1] CHECKPOINT [task-001] step=1/1 "已定义 rust_tree_shell_runtime_artifact_v1,并让 mnote-web rendererInput 与 3000 inline host 同步输出输入/输出/事件边界。"
|
|
||||||
[2026-04-26T23:22:39Z] [SESSION-1] Completed [task-001] (commit skipped by repo rule; validated by mnote-web renderer_input/runtime_artifact tests and tree-shell-iframe-host vitest)
|
|
||||||
[2026-04-26T23:22:39Z] [SESSION-1] Starting [task-002] Phase K-2:建立 page tree Rust-side runtime 状态机 (base=6d89f5ca)
|
|
||||||
[2026-04-26T23:22:39Z] [SESSION-1] STATS tasks_total=9 completed=1 failed=0 pending=7 in_progress=1 blocked=0 attempts_total=2 checkpoints=1
|
|
||||||
[2026-04-26T23:27:43Z] [SESSION-1] CHECKPOINT [task-002] step=1/1 "已新增 page_runtime reducer,覆盖 focus normalize/next/previous/home/end、expand/collapse/open/context-menu intent、drop feedback 与 tree.subtree.move dispatch。"
|
|
||||||
[2026-04-26T23:27:43Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; validated by mnote-web page_renderer/page_focus/page_runtime, tree-shell iframe/surface vitest, and task112 smoke)
|
|
||||||
[2026-04-26T23:27:43Z] [SESSION-1] Starting [task-003] Phase K-3:建立 file tree Rust-side runtime 状态机 (base=6d89f5ca)
|
|
||||||
[2026-04-26T23:27:43Z] [SESSION-1] STATS tasks_total=9 completed=2 failed=0 pending=6 in_progress=1 blocked=0 attempts_total=3 checkpoints=2
|
|
||||||
[2026-04-26T23:32:11Z] [SESSION-1] CHECKPOINT [task-003] step=1/1 "已新增 filetree_runtime reducer,覆盖 selection/range/context/normalize、drag row ids、copy/move effect、drop target feedback 与 doc/index/asset-folder/asset intent。"
|
|
||||||
[2026-04-26T23:32:11Z] [SESSION-1] Completed [task-003] (commit skipped by repo rule; validated by mnote-web filetree_renderer/filetree_selection/filetree_runtime, iframe/file-tree/tree-delta vitest, and task112 smoke)
|
|
||||||
[2026-04-26T23:32:11Z] [SESSION-1] Starting [task-004] Phase K-4:建立 picker Rust-side runtime 状态机 (base=6d89f5ca)
|
|
||||||
[2026-04-26T23:32:11Z] [SESSION-1] STATS tasks_total=9 completed=3 failed=0 pending=5 in_progress=1 blocked=0 attempts_total=4 checkpoints=3
|
|
||||||
[2026-04-26T23:34:59Z] [SESSION-1] CHECKPOINT [task-004] step=1/1 "已新增 picker_runtime reducer,覆盖 normalize/next/previous/home/end/focus/pick、excluded ids、root pick 与 focus_dom 搜索输入边界。"
|
|
||||||
[2026-04-26T23:34:59Z] [SESSION-1] Completed [task-004] (commit skipped by repo rule; validated by mnote-web picker_renderer/picker_state/picker_runtime, picker dialog + iframe vitest, and task113 smoke)
|
|
||||||
[2026-04-26T23:34:59Z] [SESSION-1] Starting [task-005] Phase K-5:3000 host 调用正式 runtime artifact,并将 mnote-web /tree 固定为 debug/internal (base=6d89f5ca)
|
|
||||||
[2026-04-26T23:34:59Z] [SESSION-1] STATS tasks_total=9 completed=4 failed=0 pending=4 in_progress=1 blocked=0 attempts_total=5 checkpoints=4
|
|
||||||
[2026-04-26T23:38:55Z] [SESSION-1] CHECKPOINT [task-005] step=1/1 "已固定 3000 inline host 与 mnote-web debug shell 均输出 rust_tree_shell_runtime_artifact_v1;task112/task113 smoke 证明 3000 主路径保持 inline iframe host 且 direct tree shell debug 默认禁用。"
|
|
||||||
[2026-04-26T23:38:55Z] [SESSION-1] Completed [task-005] (commit skipped by repo rule; validated by mnote-web tree_shell_embeds_renderer_input_contract, tree-shell iframe/surface vitest, task112 and task113 smoke)
|
|
||||||
[2026-04-26T23:38:55Z] [SESSION-1] Starting [task-006] Phase L:将 tree.subtree.move 最终写路径继续下沉 Rust kernel (base=6d89f5ca)
|
|
||||||
[2026-04-26T23:38:55Z] [SESSION-1] STATS tasks_total=9 completed=5 failed=0 pending=3 in_progress=1 blocked=0 attempts_total=6 checkpoints=5
|
|
||||||
[2026-04-26T23:52:20Z] [SESSION-2] LOCK acquired (pid=3897587)
|
|
||||||
[2026-04-26T23:52:20Z] [SESSION-2] RECOVERY [task-006] action="validate existing uncommitted changes" reason="previous session left task-006 in_progress after implementation and validation"
|
|
||||||
[2026-04-26T23:52:52Z] [SESSION-2] CHECKPOINT [task-006] step=1/1 "已引入 Rust treeWriteOperation,Convex 优先执行 Rust write operation;目标验证通过。"
|
|
||||||
[2026-04-26T23:52:52Z] [SESSION-2] Completed [task-006] (commit skipped by repo rule; validated by bridge-runtime tree_subtree_move_command and 39 frontend target tests)
|
|
||||||
[2026-04-26T23:52:52Z] [SESSION-2] Starting [task-007] Phase M:补齐 block/save-snapshot formal artifact 与 domain-event contract (base=6d89f5ca)
|
|
||||||
[2026-04-26T23:52:52Z] [SESSION-2] STATS tasks_total=9 completed=6 failed=0 pending=2 in_progress=1 blocked=0 attempts_total=7 checkpoints=6
|
|
||||||
[2026-04-27T00:09:12Z] [SESSION-2] CHECKPOINT [task-007] step=1/1 "formal domainEventPlan payload schema 与 resync_required delta 已落地;目标验证通过。"
|
|
||||||
[2026-04-27T00:09:12Z] [SESSION-2] Completed [task-007] (commit skipped by repo rule; validated by bridge-runtime formal contract tests and frontend adapter/runtime tests)
|
|
||||||
[2026-04-27T00:09:12Z] [SESSION-2] Starting [task-008] Phase N:继续收薄 3000 Next route 过渡职责 (base=6d89f5ca)
|
|
||||||
[2026-04-27T00:09:12Z] [SESSION-2] STATS tasks_total=9 completed=7 failed=0 pending=1 in_progress=1 blocked=0 attempts_total=8 checkpoints=7
|
|
||||||
[2026-04-27T00:18:38Z] [SESSION-3] LOCK acquired (pid=codex-session-3)
|
|
||||||
[2026-04-27T00:18:38Z] [SESSION-3] RECOVERY [task-008] action="resume diagnostics with uncommitted changes" reason="previous session implemented Phase N but task112 picker move wait timed out"
|
|
||||||
[2026-04-27T00:28:56Z] [SESSION-3] CHECKPOINT [task-008] step=1/2 "已定位 task112 超时根因:move 请求实际发出但 Convex 运行态 documents.move validator 未加载 treeWriteOperation,执行 npx convex dev --once 刷新本地 functions。"
|
|
||||||
[2026-04-27T00:34:43Z] [SESSION-3] CHECKPOINT [task-008] step=2/2 "目标 Vitest 与 task112 smoke 通过;补充 iframe ready 等待,运行态 Convex functions 已刷新。"
|
|
||||||
[2026-04-27T00:34:43Z] [SESSION-3] Completed [task-008] (commit skipped by repo rule; validated by tree route/delta/boundary vitest and task112 smoke)
|
|
||||||
[2026-04-27T00:34:43Z] [SESSION-3] Starting [task-009] Phase O:搜索语义 hardening 与资源类型 fixture 补齐 (base=6d89f5ca)
|
|
||||||
[2026-04-27T00:41:58Z] [SESSION-3] CHECKPOINT [task-009] step=1/1 "file_tree 搜索 hardening 前端契约与资源 fixture 已完成;目标验证通过。"
|
|
||||||
[2026-04-27T00:41:58Z] [SESSION-3] Completed [task-009] (commit skipped by repo rule; validated by tree-delta, projection-client, kernel-file-tree tests and task112 smoke)
|
|
||||||
[2026-04-27T00:41:58Z] [SESSION-3] STATS tasks_total=9 completed=9 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=9 checkpoints=9
|
|
||||||
[2026-04-27T00:41:58Z] [SESSION-3] LOCK released
|
|
||||||
[2026-04-28T00:00:00Z] [SESSION-4] CORRECTION final-renderer-gate="task-001..task-009 were Rust/WASM reducer runtime completion, not final DOM shell completion"
|
|
||||||
[2026-04-28T00:00:00Z] [SESSION-4] INIT Added task-010..task-013 from design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md
|
|
||||||
[2026-04-28T00:00:00Z] [SESSION-4] NEXT task-010 must add negative gates so default page/filetree/picker success path cannot remain iframe_srcdoc inline DOM shell
|
|
||||||
[2026-04-28T00:00:00Z] [SESSION-4] STATS tasks_total=13 completed=9 failed=0 pending=4 in_progress=0 blocked=0 attempts_total=9 checkpoints=9
|
|
||||||
[2026-04-28T02:44:47Z] [SESSION-5] LOCK acquired (pid=manual-codex)
|
|
||||||
[2026-04-28T02:44:47Z] [SESSION-5] INIT Harness environment check: PASS
|
|
||||||
[2026-04-28T02:44:47Z] [SESSION-5] Starting [task-010] Phase P-0:补 final DOM shell 负向门禁,阻止 iframe_srcdoc 被误判为完成 (base=81ba21c5)
|
|
||||||
[2026-04-28T02:52:03Z] [SESSION-6] LOCK acquired (pid=manual-codex-session-6)
|
|
||||||
[2026-04-28T02:52:03Z] [SESSION-6] RECOVERY [task-010] action="resume in-progress final DOM shell gate" reason="previous session lock stale; preserve existing uncommitted changes"
|
|
||||||
[2026-04-28T03:28:16.005752Z] [SESSION-6] CHECKPOINT [task-010] step=1/4 "已新增默认非 iframe rust_wasm_dom_shell_host 与共享 DOM projection model,legacy iframe 改为显式环境开关。"
|
|
||||||
[2026-04-28T03:34:10.533972Z] [SESSION-6] CHECKPOINT [task-010] step=2/4 "tree-shell-surface 与 legacy iframe host 目标 Vitest 通过;默认主路径断言已切成 rust_wasm_dom_shell_host 且负向排除 iframe_srcdoc。"
|
|
||||||
[2026-04-28T03:36:24.605315Z] [SESSION-6] CHECKPOINT [task-010] step=3/4 "move/embed picker 测试已切到 DOM shell;默认 picker 不再以 iframe postMessage 为成功条件,目标前端 Vitest 29 项通过。"
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] RECOVERY [task-010] action="resume stale leased final DOM shell cutover" reason="previous session left task-010 in_progress; existing changes validated without rollback"
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] CHECKPOINT [task-010] step=4/4 "已补默认 DOM shell 负向门禁:tree-shell-host/surface/picker 测试与 smoke 默认拒绝 iframe_srcdoc 成功路径。"
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] Completed [task-010] (commit skipped by repo rule; validated by Vitest 31 tests, mnote-web tree_shell 34 tests, tree-shell-runtime-wasm 18 tests, node --check task112/task113, task112/task113 browser smoke, tsc filtered check, and git diff --check)
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] Starting [task-011] Phase P-1:拆分默认 Rust DOM shell host 与 legacy TreeShellIframeHost (base=81ba21c5)
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] CHECKPOINT [task-011] step=1/1 "TreeShellHost 默认选择 rust_wasm_dom_shell_host;TreeShellIframeHost 仅由 NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST=1 进入。"
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] Completed [task-011] (commit skipped by repo rule; validated by Vitest 31 tests, mnote-web tree_shell 34 tests, tree-shell-runtime-wasm 18 tests, node --check task112/task113, task112/task113 browser smoke, tsc filtered check, and git diff --check)
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] Starting [task-012] Phase Q:page / picker / filetree 迁入 Rust/WASM DOM shell 主路径 (base=81ba21c5)
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] CHECKPOINT [task-012] step=1/1 "page / filetree / picker 默认 DOM host 已消费 TreeShellRuntimeRequest/Result,并由 WASM artifact 或同源 Rust seam 驱动 hostEvents/commandEvents。"
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] Completed [task-012] (commit skipped by repo rule; validated by Vitest 31 tests, mnote-web tree_shell 34 tests, tree-shell-runtime-wasm 18 tests, node --check task112/task113, task112/task113 browser smoke, tsc filtered check, and git diff --check)
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] Starting [task-013] Phase R/S:删除默认 JS DOM state machine 并完成文档收口 (base=81ba21c5)
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] CHECKPOINT [task-013] step=1/1 "已清理 1.md、4-11、4-16、4-18 的 final DOM shell 口径;旧 JS renderer/state machine 只作为 legacy iframe host 保留。"
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] Completed [task-013] (commit skipped by repo rule; validated by Vitest 31 tests, mnote-web tree_shell 34 tests, tree-shell-runtime-wasm 18 tests, node --check task112/task113, task112/task113 browser smoke, tsc filtered check, and git diff --check)
|
|
||||||
[2026-04-28T04:40:14Z] [SESSION-7] STATS tasks_total=13 completed=13 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=13 checkpoints=16
|
|
||||||
[2026-04-28T06:29:15Z] [SESSION-7] INIT Added task-014..task-018 for remaining 4-16 final runtime closure: normalizedMove fallback removal, snapshot event/Page Aggregate, embed pageReference migration, and file_tree indexing visibility.
|
|
||||||
[2026-04-28T06:29:15Z] [SESSION-7] Starting [task-014] Phase L2:删除 normalizedMove fallback,强制 move 主写路径使用 treeWriteOperation (base=81ba21c5)
|
|
||||||
[2026-04-28T06:37:40Z] [SESSION-7] CHECKPOINT [task-014] step=1/1 "已删除 normalizedMove fallback;bridge-runtime/TS transport/Convex move 写路径只保留 treeWriteOperation,目标测试通过。"
|
|
||||||
[2026-04-28T06:37:40Z] [SESSION-7] Completed [task-014] (commit skipped by repo rule; validated by bridge-runtime tree_subtree_move_command and 20 frontend target tests)
|
|
||||||
[2026-04-28T06:37:40Z] [SESSION-7] Starting [task-015] Phase M2:定稿 document.snapshot.saved 独立事件并避免与 page.body.saved payload 重叠 (base=81ba21c5)
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-015] step=1/1 "page.body.saved 与 document.snapshot.saved 已拆成 domainEventPlans 双事件;artifact writer 支持 domainEvents 批量落库,page.body.saved payload 不再夹带 snapshot。"
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-015] (commit skipped by repo rule; validated by bridge-runtime --lib and rust-runtime/bridge-log/page-write tests)
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] Starting [task-016] Phase M3:把 snapshot 独立事件接入 Page Aggregate 深层收口文档与客户端边界 (base=81ba21c5)
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-016] step=1/1 "Page Aggregate contract 与 5-6 清单已固定 page.body.saved / document.snapshot.saved 职责边界;4-16 snapshot 项已勾选。"
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-016] (commit skipped by repo rule; validated by page-aggregate-client-state/page-aggregate-builder/page-write tests and git diff --check)
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] Starting [task-017] Phase N2:将 tree.node.embed 的 pageReference 组装迁到 Page Aggregate / Rust artifact 边界 (base=81ba21c5)
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-017] step=1/1 "Rust pageAggregateEmbedPlan 已产出 pageReference block 与 next content;3000 route/documents embed adapter 只传 substrate preflight,tree-route-boundary compatPending 已清空。"
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-017] (commit skipped by repo rule; validated by bridge-runtime --lib, route tests, and tree-route-boundary tests)
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] Starting [task-018] Phase O2:补 file_tree 搜索索引可见性指标边界并收口 4-16 (base=81ba21c5)
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-018] step=1/1 "file_tree 搜索 indexingVisibility 已提升为 projection result meta;mnote-web 与 3000 同源 projection route 均透传该指标,4-16 无未勾选项。"
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-018] (commit skipped by repo rule; validated by bridge-runtime --lib, mnote-web file_tree_projection, file projection route/client tests)
|
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] STATS tasks_total=18 completed=18 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=18 checkpoints=20
|
|
||||||
[2026-04-28T08:53:21Z] [SESSION-9] INIT Added task-019..task-026 from design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md for Rust Web main execution plane cutover.
|
|
||||||
[2026-04-28T08:53:21Z] [SESSION-9] NEXT task-019 must freeze main execution owner manifest before gateway implementation.
|
|
||||||
[2026-04-28T08:53:21Z] [SESSION-9] STATS tasks_total=26 completed=18 failed=0 pending=8 in_progress=0 blocked=0 attempts_total=18 checkpoints=21
|
|
||||||
[2026-04-28T09:06:04Z] [SESSION-9] LOCK acquired (pid=codex-main-session)
|
|
||||||
[2026-04-28T09:06:04Z] [SESSION-9] Starting [task-019] Phase W-0:冻结 Rust Web 主执行面 owner manifest 与负向门禁 (base=9f6a2c86)
|
|
||||||
[2026-04-28T09:07:58Z] [SESSION-9] CHECKPOINT [task-019] step=1/1 "已新增 Rust Web 主执行面 owner manifest 与负向门禁;目标 Vitest 与 diff check 通过。"
|
|
||||||
[2026-04-28T09:07:58Z] [SESSION-9] Completed [task-019] (commit skipped by repo rule; validated by rust-web-main-execution-boundary/runtime-config vitest and git diff --check)
|
|
||||||
[2026-04-28T09:07:58Z] [SESSION-9] Starting [task-020] Phase W-1:让 mnote-web 默认成为 3000 gateway owner (base=9f6a2c86)
|
|
||||||
[2026-04-28T09:13:44Z] [SESSION-9] CHECKPOINT [task-020] step=1/1 "mnote-web gateway owner、desktop-hot 默认启动计划与 task114 smoke 已落地;目标验证通过。"
|
|
||||||
[2026-04-28T09:13:44Z] [SESSION-9] Completed [task-020] (commit skipped by repo rule; validated by desktop-hot node tests, mnote-web gateway cargo tests, and task114 smoke)
|
|
||||||
[2026-04-28T09:13:44Z] [SESSION-9] Starting [task-021] Phase W-2:把 runtime config 与 auth/session handoff 迁到 Rust Web (base=9f6a2c86)
|
|
||||||
[2026-04-28T09:16:15Z] [SESSION-9] WARN validation.commands="修正 task-021..task-025 Cargo 多过滤词写法;原命令会被 cargo test 拒绝为 unexpected argument。"
|
|
||||||
[2026-04-28T09:16:28Z] [SESSION-9] CHECKPOINT [task-021] step=1/1 "mnote-web runtime config/session routes 与 Next token compat/debug route 已落地;修正后目标验证通过。"
|
|
||||||
[2026-04-28T09:16:28Z] [SESSION-9] Completed [task-021] (commit skipped by repo rule; validated by mnote-web session/runtime_config tests and runtime-config/token vitest)
|
|
||||||
[2026-04-28T09:16:28Z] [SESSION-9] Starting [task-022] Phase W-3:建立 Rust-owned 文档页 / Page Aggregate server-first shell (base=9f6a2c86)
|
|
||||||
[2026-04-28T09:20:22Z] [SESSION-9] CHECKPOINT [task-022] step=1/1 "Rust-owned document shell 与 Page Aggregate snapshot contract 已落地;目标验证通过。"
|
|
||||||
[2026-04-28T09:20:22Z] [SESSION-9] Completed [task-022] (commit skipped by repo rule; validated by document_shell/page_aggregate tests, document-content/page-aggregate-builder vitest, and task115 smoke)
|
|
||||||
[2026-04-28T09:20:22Z] [SESSION-9] Starting [task-023] Phase W-4:把主 API transport 从 Next route 收薄为 Rust Web owned (base=9f6a2c86)
|
|
||||||
[2026-04-28T09:26:14Z] [SESSION-9] CHECKPOINT [task-023] step=1/1 "Rust Web documents/tree/search transport owner 口径已落地;目标验证通过,page_aggregate 过滤词命中 0 项已记录为配置缺口。"
|
|
||||||
[2026-04-28T09:26:14Z] [SESSION-9] Completed [task-023] (commit skipped by repo rule; validated by mnote-web documents_api/tree_commands/search tests, bridge-runtime tree_subtree_move_command tests, and frontend route/search tests)
|
|
||||||
[2026-04-28T09:26:14Z] [SESSION-9] Starting [task-024] Phase W-5:把 tree realtime stream 固定为 Rust Web 主链 (base=9f6a2c86)
|
|
||||||
[2026-04-28T12:33:41Z] [SESSION-10] CHECKPOINT [task-024] step=1/1 "Rust Web /api/tree/events owner、snapshot/delta stream、Next /api/mnote-web/stream compat alias 与 consumer URL cutover 已通过验证;task112 legacy renderer crash 仅在 Target crashed 时新 context 恢复。"
|
|
||||||
[2026-04-28T12:33:41Z] [SESSION-10] Completed [task-024] (commit skipped by repo rule; validated by mnote-web tree_realtime/stream tests, frontend stream/documents route tests, and task112 smoke)
|
|
||||||
[2026-04-28T12:33:41Z] [SESSION-10] NEXT task-025 Phase W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands
|
|
||||||
[2026-04-28T12:34:53Z] [SESSION-10] Starting [task-025] Phase W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands (base=9f6a2c86)
|
|
||||||
[2026-04-28T12:34:53Z] [SESSION-10] CHECKPOINT [task-025] step=0/4 "初始 Rust 目标过滤词 search_shell/ai_bridge/mindmap_shell 命中 0 tests;先补 owner/contract 门禁。"
|
|
||||||
[2026-04-28T12:44:04Z] [SESSION-10] CHECKPOINT [task-025] step=1/1 "Search / AI / Mindmap shell owner 已迁到 Rust Web;Hermes contract 固定 session/tool/client action owner;React runtime 标记为 island 或 legacy compat。"
|
|
||||||
[2026-04-28T12:44:04Z] [SESSION-10] Completed [task-025] (commit skipped by repo rule; validated by mnote-web search_shell/ai_bridge/mindmap_shell tests, frontend AI/search target tests, task116 smoke, and git diff --check)
|
|
||||||
[2026-04-28T12:44:04Z] [SESSION-10] NEXT task-026 Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口
|
|
||||||
[2026-04-28T12:50:07Z] [SESSION-10] Starting [task-026] Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口 (base=9f6a2c86)
|
|
||||||
[2026-04-28T12:50:07Z] [SESSION-10] CHECKPOINT [task-026] step=1/1 "task117 证明 SKIP_NEXT_LEGACY=1 核心 shell 可脱离 Next legacy;desktop-hot 跳过 Next legacy 分支、ARCHITECTURE/3-1 口径与 3-5/3-6 done 迁移已完成。"
|
|
||||||
[2026-04-28T12:50:07Z] [SESSION-10] Completed [task-026] (commit skipped by repo rule; validated by task097/task114/task115/task116/task117 and git diff --check)
|
|
||||||
[2026-04-28T12:50:07Z] [SESSION-10] STATS tasks_total=26 completed=26 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=26 checkpoints=29
|
|
||||||
@@ -101,14 +101,12 @@ const DEFAULT_TOOLS: MnoteToolManifestEntry[] = [
|
|||||||
|
|
||||||
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");
|
const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");
|
||||||
|
const EMBEDDED_CONTEXT: Record<string, unknown> | undefined = undefined;
|
||||||
const CONTEXT_FILE = env("PI_MNOTE_CONTEXT_FILE")
|
const CONTEXT_FILE = env("PI_MNOTE_CONTEXT_FILE")
|
||||||
|| (fs.existsSync(DEFAULT_CONTEXT_FILE) ? DEFAULT_CONTEXT_FILE : "");
|
|| DEFAULT_CONTEXT_FILE;
|
||||||
const RUNTIME_IMPL = env("PI_MNOTE_RUNTIME_IMPL")
|
const RUNTIME_IMPL = env("PI_MNOTE_RUNTIME_IMPL")
|
||||||
|| env("MNOTE_PI_RUNTIME_IMPL")
|
|| env("MNOTE_PI_RUNTIME_IMPL")
|
||||||
|| (CONTEXT_FILE ? "pi-rust" : "");
|
|| (CONTEXT_FILE ? "pi-rust" : "");
|
||||||
const BASE_URL = env("PI_MNOTE_BRIDGE_BASE_URL") || env("MNOTE_PI_BRIDGE_BASE_URL") || env("MNOTE_PI_LAB_BASE_URL") || "http://127.0.0.1:3000";
|
|
||||||
const SESSION_ID = env("PI_MNOTE_BRIDGE_SESSION_ID") || env("MNOTE_PI_BRIDGE_SESSION_ID") || env("MNOTE_PI_LAB_SESSION_ID") || "";
|
|
||||||
const BRIDGE_TOKEN = env("PI_MNOTE_BRIDGE_TOKEN") || env("MNOTE_PI_BRIDGE_TOKEN") || env("MNOTE_PI_LAB_BRIDGE_TOKEN") || "";
|
|
||||||
const HTTP_BRIDGE_AVAILABLE = env("PI_MNOTE_HTTP_BRIDGE_AVAILABLE") === "1";
|
const HTTP_BRIDGE_AVAILABLE = env("PI_MNOTE_HTTP_BRIDGE_AVAILABLE") === "1";
|
||||||
const TOOL_POLICIES = parseJsonRecord(env("PI_MNOTE_BRIDGE_TOOL_POLICIES") || env("MNOTE_PI_BRIDGE_TOOL_POLICIES"), {});
|
const TOOL_POLICIES = parseJsonRecord(env("PI_MNOTE_BRIDGE_TOOL_POLICIES") || env("MNOTE_PI_BRIDGE_TOOL_POLICIES"), {});
|
||||||
const TOOLS = normalizeTools(parseJsonUnknown(env("PI_MNOTE_BRIDGE_TOOLS") || env("MNOTE_PI_BRIDGE_TOOLS")) ?? DEFAULT_TOOLS);
|
const TOOLS = normalizeTools(parseJsonUnknown(env("PI_MNOTE_BRIDGE_TOOLS") || env("MNOTE_PI_BRIDGE_TOOLS")) ?? DEFAULT_TOOLS);
|
||||||
@@ -119,6 +117,10 @@ function env(name: string): string {
|
|||||||
return (process.env[name] || "").trim();
|
return (process.env[name] || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contextFileExists(): boolean {
|
||||||
|
return Boolean(CONTEXT_FILE) && fs.existsSync(CONTEXT_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
function parseJsonUnknown(raw: string): unknown {
|
function parseJsonUnknown(raw: string): unknown {
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
try {
|
try {
|
||||||
@@ -157,6 +159,23 @@ function stringField(record: Record<string, unknown>, key: string): string {
|
|||||||
return typeof value === "string" ? value.trim() : "";
|
return typeof value === "string" ? value.trim() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordField(record: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
|
||||||
|
if (!record) return undefined;
|
||||||
|
const value = record[key];
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedContextPart(context: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
|
||||||
|
return recordField(recordField(context, "selectedContext"), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextRefs(context: Record<string, unknown>): string[] {
|
||||||
|
const refs = context.contextRefs;
|
||||||
|
return Array.isArray(refs) ? refs.filter((ref): ref is string => typeof ref === "string") : [];
|
||||||
|
}
|
||||||
|
|
||||||
function toolResult(payload: Record<string, unknown>, isError = false) {
|
function toolResult(payload: Record<string, unknown>, isError = false) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||||
@@ -201,10 +220,9 @@ function captureInputContext(event: Record<string, unknown>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readContextSnapshot(): Record<string, unknown> {
|
function readContextFileSnapshot(): Record<string, unknown> {
|
||||||
if (liveContext) return liveContext;
|
if (!contextFileExists()) {
|
||||||
if (!CONTEXT_FILE) {
|
throw new Error("MNote Pi 上下文快照不可用;Pi Rust bridge 需要 input hook、embedded context 或 context file");
|
||||||
throw new Error("PI_MNOTE_CONTEXT_FILE 未配置");
|
|
||||||
}
|
}
|
||||||
const raw = fs.readFileSync(CONTEXT_FILE, "utf8");
|
const raw = fs.readFileSync(CONTEXT_FILE, "utf8");
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
@@ -214,6 +232,63 @@ function readContextSnapshot(): Record<string, unknown> {
|
|||||||
return parsed as Record<string, unknown>;
|
return parsed as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function embeddedContextSnapshot(): Record<string, unknown> | undefined {
|
||||||
|
return EMBEDDED_CONTEXT && typeof EMBEDDED_CONTEXT === "object" && !Array.isArray(EMBEDDED_CONTEXT)
|
||||||
|
? EMBEDDED_CONTEXT
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeContextSnapshot(): Record<string, unknown> | undefined {
|
||||||
|
try {
|
||||||
|
if (contextFileExists()) return readContextFileSnapshot();
|
||||||
|
} catch {
|
||||||
|
// Fall back to the input-hook snapshot when the host cannot read files.
|
||||||
|
}
|
||||||
|
return liveContext || embeddedContextSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readContextSnapshot(): Record<string, unknown> {
|
||||||
|
const snapshot = activeContextSnapshot();
|
||||||
|
if (snapshot) return snapshot;
|
||||||
|
return readContextFileSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeToken(): string {
|
||||||
|
const fromEnv = env("PI_MNOTE_BRIDGE_TOKEN")
|
||||||
|
|| env("MNOTE_PI_BRIDGE_TOKEN")
|
||||||
|
|| env("MNOTE_PI_LAB_BRIDGE_TOKEN");
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
const fromContext = activeContextSnapshot();
|
||||||
|
if (fromContext) {
|
||||||
|
const value = stringField(fromContext, "bridgeToken");
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeBaseUrl(): string {
|
||||||
|
const fromEnv = env("PI_MNOTE_BRIDGE_BASE_URL")
|
||||||
|
|| env("MNOTE_PI_BRIDGE_BASE_URL")
|
||||||
|
|| env("MNOTE_PI_LAB_BASE_URL");
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
const fromContext = activeContextSnapshot();
|
||||||
|
if (fromContext) {
|
||||||
|
const value = stringField(fromContext, "bridgeBaseUrl");
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return "http://127.0.0.1:3000";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeSessionId(): string {
|
||||||
|
const fromContext = activeContextSnapshot();
|
||||||
|
const fromContextSession = fromContext ? stringField(fromContext, "sessionId") : "";
|
||||||
|
if (fromContextSession) return fromContextSession;
|
||||||
|
const fromEnv = env("PI_MNOTE_BRIDGE_SESSION_ID")
|
||||||
|
|| env("MNOTE_PI_BRIDGE_SESSION_ID");
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function contextAllowedRoots(context: Record<string, unknown>): Record<string, unknown>[] {
|
function contextAllowedRoots(context: Record<string, unknown>): Record<string, unknown>[] {
|
||||||
const snapshot = context.allowedRoots;
|
const snapshot = context.allowedRoots;
|
||||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return [];
|
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return [];
|
||||||
@@ -233,6 +308,7 @@ function resolveAllowedTarget(
|
|||||||
context: Record<string, unknown>,
|
context: Record<string, unknown>,
|
||||||
rootUri: string,
|
rootUri: string,
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
|
options?: { allowMissing?: boolean },
|
||||||
): { target: string; readPath: string } {
|
): { target: string; readPath: string } {
|
||||||
const roots = contextAllowedRoots(context);
|
const roots = contextAllowedRoots(context);
|
||||||
const matchedRoot = roots.find((root) => stringField(root, "rootUri") === rootUri);
|
const matchedRoot = roots.find((root) => stringField(root, "rootUri") === rootUri);
|
||||||
@@ -245,7 +321,9 @@ function resolveAllowedTarget(
|
|||||||
const requestedTarget = path.isAbsolute(relativePath)
|
const requestedTarget = path.isAbsolute(relativePath)
|
||||||
? path.resolve(relativePath)
|
? path.resolve(relativePath)
|
||||||
: path.resolve(canonicalRoot, relativePath);
|
: path.resolve(canonicalRoot, relativePath);
|
||||||
const canonicalTarget = fs.realpathSync(requestedTarget);
|
const canonicalTarget = options?.allowMissing
|
||||||
|
? canonicalOrParent(requestedTarget)
|
||||||
|
: fs.realpathSync(requestedTarget);
|
||||||
const allowedRoots = roots
|
const allowedRoots = roots
|
||||||
.map((root) => stringField(root, "rootPath"))
|
.map((root) => stringField(root, "rootPath"))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -261,16 +339,96 @@ function resolveAllowedTarget(
|
|||||||
return { target: canonicalTarget, readPath };
|
return { target: canonicalTarget, readPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalOrParent(target: string): string {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(target);
|
||||||
|
} catch {
|
||||||
|
const parent = path.dirname(target);
|
||||||
|
try {
|
||||||
|
return path.join(fs.realpathSync(parent), path.basename(target));
|
||||||
|
} catch {
|
||||||
|
return path.resolve(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionRootUri(context: Record<string, unknown>, input: Record<string, unknown>): string {
|
||||||
|
return stringField(input, "rootUri")
|
||||||
|
|| stringField(input, "root_uri")
|
||||||
|
|| stringField(selectedContextPart(context, "currentPage") || {}, "rootUri")
|
||||||
|
|| stringField(selectedContextPart(context, "currentFolder") || {}, "rootUri")
|
||||||
|
|| stringField(context, "rootUri");
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinRelativePath(basePath: string, childPath: string): string {
|
||||||
|
const normalizedChild = childPath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||||
|
const normalizedBase = basePath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||||
|
if (!normalizedBase || path.isAbsolute(childPath)) return normalizedChild;
|
||||||
|
if (!normalizedChild || normalizedChild === ".") return normalizedBase;
|
||||||
|
if (normalizedChild === normalizedBase || normalizedChild.startsWith(`${normalizedBase}/`)) return normalizedChild;
|
||||||
|
return `${normalizedBase}/${normalizedChild}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionRelativePath(context: Record<string, unknown>, input: Record<string, unknown>, options?: { preferFolder?: boolean }): string {
|
||||||
|
const explicitPath = stringField(input, "relativePath")
|
||||||
|
|| stringField(input, "relative_path")
|
||||||
|
|| stringField(input, "path");
|
||||||
|
const folderPath = stringField(input, "folderPath")
|
||||||
|
|| stringField(input, "folder_path")
|
||||||
|
|| stringField(selectedContextPart(context, "currentFolder") || {}, "folderPath");
|
||||||
|
if (explicitPath) {
|
||||||
|
return options?.preferFolder ? joinRelativePath(folderPath, explicitPath) : explicitPath;
|
||||||
|
}
|
||||||
|
return stringField(input, "pagePath")
|
||||||
|
|| stringField(input, "page_path")
|
||||||
|
|| stringField(selectedContextPart(context, "currentPage") || {}, "pagePath")
|
||||||
|
|| folderPath
|
||||||
|
|| stringField(context, "pagePath");
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextualToolParams(toolName: string, params: unknown): Record<string, unknown> {
|
||||||
|
const nextParams = { ...((params || {}) as Record<string, unknown>) };
|
||||||
|
const context = activeContextSnapshot();
|
||||||
|
if (!context) return nextParams;
|
||||||
|
const currentPage = selectedContextPart(context, "currentPage");
|
||||||
|
const currentFolder = selectedContextPart(context, "currentFolder");
|
||||||
|
const selection = selectedContextPart(context, "selection");
|
||||||
|
if (!stringField(nextParams, "rootUri")) {
|
||||||
|
nextParams.rootUri = stringField(currentPage || {}, "rootUri")
|
||||||
|
|| stringField(currentFolder || {}, "rootUri")
|
||||||
|
|| stringField(context, "rootUri")
|
||||||
|
|| undefined;
|
||||||
|
}
|
||||||
|
if (!stringField(nextParams, "workspaceId")) {
|
||||||
|
nextParams.workspaceId = stringField(currentPage || {}, "workspaceId")
|
||||||
|
|| stringField(currentFolder || {}, "workspaceId")
|
||||||
|
|| stringField(context, "workspaceId")
|
||||||
|
|| undefined;
|
||||||
|
}
|
||||||
|
if (!stringField(nextParams, "pagePath")) {
|
||||||
|
nextParams.pagePath = stringField(currentPage || {}, "pagePath") || undefined;
|
||||||
|
}
|
||||||
|
if (!stringField(nextParams, "folderPath")) {
|
||||||
|
nextParams.folderPath = stringField(currentFolder || {}, "folderPath") || undefined;
|
||||||
|
}
|
||||||
|
if (toolName === "mnote.selection.read") {
|
||||||
|
nextParams.selectionSource = "mnote_sidebar_host";
|
||||||
|
nextParams.selection = selection || null;
|
||||||
|
}
|
||||||
|
return nextParams;
|
||||||
|
}
|
||||||
|
|
||||||
function executeNativeCurrentPageRead(params: unknown) {
|
function executeNativeCurrentPageRead(params: unknown) {
|
||||||
try {
|
try {
|
||||||
const input = params && typeof params === "object" && !Array.isArray(params)
|
const input = params && typeof params === "object" && !Array.isArray(params)
|
||||||
? params as Record<string, unknown>
|
? params as Record<string, unknown>
|
||||||
: {};
|
: {};
|
||||||
const context = readContextSnapshot();
|
const context = readContextSnapshot();
|
||||||
const rootUri = stringField(context, "rootUri")
|
const currentPage = selectedContextPart(context, "currentPage");
|
||||||
|
const rootUri = stringField(currentPage || {}, "rootUri")
|
||||||
|| stringField(input, "rootUri")
|
|| stringField(input, "rootUri")
|
||||||
|| stringField(input, "root_uri");
|
|| stringField(input, "root_uri");
|
||||||
const pagePath = stringField(context, "pagePath")
|
const pagePath = stringField(currentPage || {}, "pagePath")
|
||||||
|| stringField(input, "pagePath")
|
|| stringField(input, "pagePath")
|
||||||
|| stringField(input, "page_path")
|
|| stringField(input, "page_path")
|
||||||
|| stringField(input, "path");
|
|| stringField(input, "path");
|
||||||
@@ -291,6 +449,8 @@ function executeNativeCurrentPageRead(params: unknown) {
|
|||||||
fileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
|
fileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
|
||||||
transport: "pi-rust-native-fs",
|
transport: "pi-rust-native-fs",
|
||||||
contextFile: CONTEXT_FILE,
|
contextFile: CONTEXT_FILE,
|
||||||
|
contextRefs: contextRefs(context),
|
||||||
|
selectedContextCurrentPage: currentPage ?? null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toolResult({
|
return toolResult({
|
||||||
@@ -303,12 +463,118 @@ function executeNativeCurrentPageRead(params: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function executeNativeLocalFileRead(params: unknown) {
|
||||||
|
try {
|
||||||
|
const input = params && typeof params === "object" && !Array.isArray(params)
|
||||||
|
? params as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const context = readContextSnapshot();
|
||||||
|
const rootUri = sessionRootUri(context, input);
|
||||||
|
const relativePath = sessionRelativePath(context, input, { preferFolder: true });
|
||||||
|
if (!rootUri) throw new Error("读取文件缺少 rootUri");
|
||||||
|
if (!relativePath) throw new Error("读取文件缺少 relativePath/path");
|
||||||
|
const resolved = resolveAllowedTarget(context, rootUri, relativePath);
|
||||||
|
const content = fs.readFileSync(resolved.readPath, "utf8");
|
||||||
|
const stat = fs.statSync(resolved.readPath);
|
||||||
|
return toolResult({
|
||||||
|
ok: true,
|
||||||
|
rootUri,
|
||||||
|
relativePath,
|
||||||
|
path: resolved.target,
|
||||||
|
content,
|
||||||
|
contentLength: content.length,
|
||||||
|
fileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
|
||||||
|
transport: "pi-rust-native-fs",
|
||||||
|
contextFile: CONTEXT_FILE,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return toolResult({
|
||||||
|
ok: false,
|
||||||
|
code: "mnote_pi_rust_local_file_read_failed",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
transport: "pi-rust-native-fs",
|
||||||
|
contextFile: CONTEXT_FILE,
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextOperations(current: string, operations: unknown): string {
|
||||||
|
if (!Array.isArray(operations)) throw new Error("operations 必须是数组");
|
||||||
|
let next = current;
|
||||||
|
for (const operation of operations) {
|
||||||
|
if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
|
||||||
|
throw new Error("operation 格式无效");
|
||||||
|
}
|
||||||
|
const op = operation as Record<string, unknown>;
|
||||||
|
const type = stringField(op, "type") || stringField(op, "op");
|
||||||
|
if (type === "replace") {
|
||||||
|
const oldText = typeof op.oldText === "string" ? op.oldText : typeof op.old_text === "string" ? op.old_text : "";
|
||||||
|
const newText = typeof op.newText === "string" ? op.newText : typeof op.new_text === "string" ? op.new_text : "";
|
||||||
|
if (!oldText) throw new Error("replace operation 缺少 oldText");
|
||||||
|
const index = next.indexOf(oldText);
|
||||||
|
if (index < 0) throw new Error("replace operation 未找到 oldText");
|
||||||
|
next = next.slice(0, index) + newText + next.slice(index + oldText.length);
|
||||||
|
} else if (type === "append") {
|
||||||
|
const text = typeof op.text === "string" ? op.text : "";
|
||||||
|
next += text;
|
||||||
|
} else {
|
||||||
|
throw new Error(`不支持的 operation: ${type || "unknown"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeNativeLocalFilePatch(params: unknown) {
|
||||||
|
try {
|
||||||
|
const input = params && typeof params === "object" && !Array.isArray(params)
|
||||||
|
? params as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const context = readContextSnapshot();
|
||||||
|
const rootUri = sessionRootUri(context, input);
|
||||||
|
const relativePath = sessionRelativePath(context, input, { preferFolder: true });
|
||||||
|
if (!rootUri) throw new Error("写入文件缺少 rootUri");
|
||||||
|
if (!relativePath) throw new Error("写入文件缺少 relativePath/path");
|
||||||
|
const resolved = resolveAllowedTarget(context, rootUri, relativePath, { allowMissing: true });
|
||||||
|
const before = fs.existsSync(resolved.target) ? fs.readFileSync(resolved.target, "utf8") : "";
|
||||||
|
const next = typeof input.content === "string"
|
||||||
|
? input.content
|
||||||
|
: applyTextOperations(before, input.operations);
|
||||||
|
fs.mkdirSync(path.dirname(resolved.target), { recursive: true });
|
||||||
|
fs.writeFileSync(resolved.target, next, "utf8");
|
||||||
|
const stat = fs.statSync(resolved.target);
|
||||||
|
return toolResult({
|
||||||
|
ok: true,
|
||||||
|
rootUri,
|
||||||
|
relativePath,
|
||||||
|
path: resolved.target,
|
||||||
|
beforeFileVersion: `pi-rust-native-before-${before.length}`,
|
||||||
|
afterFileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
|
||||||
|
oldSize: before.length,
|
||||||
|
newSize: next.length,
|
||||||
|
diffSummary: before === next ? "no_changes" : `bytes_delta=${next.length - before.length}`,
|
||||||
|
refresh: "mnote local-folder watcher / document-session external refresh",
|
||||||
|
transport: "pi-rust-native-fs",
|
||||||
|
contextFile: CONTEXT_FILE,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return toolResult({
|
||||||
|
ok: false,
|
||||||
|
code: "mnote_pi_rust_local_file_patch_failed",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
transport: "pi-rust-native-fs",
|
||||||
|
contextFile: CONTEXT_FILE,
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function executeNativeSelectionRead() {
|
function executeNativeSelectionRead() {
|
||||||
try {
|
try {
|
||||||
const context = readContextSnapshot();
|
const context = readContextSnapshot();
|
||||||
|
const selection = selectedContextPart(context, "selection");
|
||||||
return toolResult({
|
return toolResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
selection: context.selectedContext ?? null,
|
selection: selection ?? null,
|
||||||
|
text: stringField(selection || {}, "text"),
|
||||||
selectionSource: "mnote_sidebar_host_snapshot",
|
selectionSource: "mnote_sidebar_host_snapshot",
|
||||||
rootUri: context.rootUri ?? null,
|
rootUri: context.rootUri ?? null,
|
||||||
pagePath: context.pagePath ?? null,
|
pagePath: context.pagePath ?? null,
|
||||||
@@ -333,6 +599,9 @@ function executeNativeAllowedRootsDescribe() {
|
|||||||
allowedRoots: context.allowedRoots ?? { roots: [] },
|
allowedRoots: context.allowedRoots ?? { roots: [] },
|
||||||
rootUri: context.rootUri ?? null,
|
rootUri: context.rootUri ?? null,
|
||||||
pagePath: context.pagePath ?? null,
|
pagePath: context.pagePath ?? null,
|
||||||
|
workspaceId: context.workspaceId ?? null,
|
||||||
|
contextRefs: context.contextRefs ?? [],
|
||||||
|
selectedContext: context.selectedContext ?? null,
|
||||||
primaryRootPath: context.primaryRootPath ?? process.cwd(),
|
primaryRootPath: context.primaryRootPath ?? process.cwd(),
|
||||||
transport: "pi-rust-native-context-file",
|
transport: "pi-rust-native-context-file",
|
||||||
});
|
});
|
||||||
@@ -346,31 +615,63 @@ function executeNativeAllowedRootsDescribe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPiRustNativeRuntime(): boolean {
|
||||||
|
return runtimeImplementation() === "pi-rust" || Boolean(CONTEXT_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
function executeNativeTool(tool: MnoteToolManifestEntry, params: unknown) {
|
function executeNativeTool(tool: MnoteToolManifestEntry, params: unknown) {
|
||||||
if (runtimeImplementation() !== "pi-rust" || toolPolicy(tool.mnoteName) !== "allow") return undefined;
|
if (!isPiRustNativeRuntime() || toolPolicy(tool.mnoteName) !== "allow") return undefined;
|
||||||
if (tool.mnoteName === "mnote.current_page.read") return executeNativeCurrentPageRead(params);
|
if (tool.mnoteName === "mnote.current_page.read") return executeNativeCurrentPageRead(params);
|
||||||
if (tool.mnoteName === "mnote.selection.read") return executeNativeSelectionRead();
|
if (tool.mnoteName === "mnote.selection.read") return executeNativeSelectionRead();
|
||||||
if (tool.mnoteName === "mnote.allowed_roots.describe") return executeNativeAllowedRootsDescribe();
|
if (tool.mnoteName === "mnote.allowed_roots.describe") return executeNativeAllowedRootsDescribe();
|
||||||
|
if (tool.mnoteName === "mnote.local_file.read") return executeNativeLocalFileRead(params);
|
||||||
|
if (tool.mnoteName === "mnote.local_file.patch") return executeNativeLocalFilePatch(params);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callMnote(toolName: string, params: unknown) {
|
async function callMnote(toolName: string, params: unknown) {
|
||||||
const response = await fetch(`${BASE_URL}/api/page-ai/pi/tool-call-bridge`, {
|
const sessionId = bridgeSessionId();
|
||||||
|
if (!sessionId) {
|
||||||
|
return toolResult({
|
||||||
|
ok: false,
|
||||||
|
code: "mnote_pi_bridge_session_id_missing",
|
||||||
|
message: "MNote Pi bridge 缺少有效的 pi_lab sessionId;已忽略 Pi runtime provider session id。",
|
||||||
|
toolName,
|
||||||
|
transport: "mnote-bridge-http",
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
const response = await fetch(`${bridgeBaseUrl()}/api/page-ai/pi/tool-call-bridge`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN,
|
"x-mnote-pi-lab-bridge-token": bridgeToken(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ sessionId: SESSION_ID, toolName, params: params || {} }),
|
body: JSON.stringify({ sessionId, toolName, params: params || {} }),
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => ({ ok: false, code: "bad_json" }));
|
const payload = await response.json().catch(() => ({ ok: false, code: "bad_json" }));
|
||||||
const text = JSON.stringify((payload as Record<string, unknown>).result || payload, null, 2);
|
const result = (payload as Record<string, unknown>).result || payload;
|
||||||
|
const text = JSON.stringify(modelSafeBridgeResult(toolName, result), null, 2);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text }],
|
||||||
details: payload,
|
details: payload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelSafeBridgeResult(toolName: string, value: unknown): unknown {
|
||||||
|
if (toolName !== "mnote.knowledge_rag.query" || !value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const result = { ...(value as Record<string, unknown>) };
|
||||||
|
delete result.uiCitations;
|
||||||
|
delete result.citationMarkdowns;
|
||||||
|
delete result.citationRendering;
|
||||||
|
result.uiCitationCount = Array.isArray((value as Record<string, unknown>).uiCitations)
|
||||||
|
? ((value as Record<string, unknown>).uiCitations as unknown[]).length
|
||||||
|
: 0;
|
||||||
|
result.uiCitationDelivery = "Clickable citations are retained in tool details for the MNote UI and omitted from model context.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function stableJson(value: unknown): string {
|
function stableJson(value: unknown): string {
|
||||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||||
@@ -394,8 +695,8 @@ function paramsHash(params: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toolPolicy(toolName: string): string {
|
function toolPolicy(toolName: string): string {
|
||||||
const contextPolicies = liveContext?.toolPolicies;
|
const contextPolicies = activeContextSnapshot()?.toolPolicies;
|
||||||
const contextValue = contextPolicies && typeof contextPolicies === "object" && !Array.isArray(contextPolicies)
|
let contextValue = contextPolicies && typeof contextPolicies === "object" && !Array.isArray(contextPolicies)
|
||||||
? (contextPolicies as Record<string, unknown>)[toolName]
|
? (contextPolicies as Record<string, unknown>)[toolName]
|
||||||
: undefined;
|
: undefined;
|
||||||
const value = contextValue ?? TOOL_POLICIES[toolName];
|
const value = contextValue ?? TOOL_POLICIES[toolName];
|
||||||
@@ -403,17 +704,27 @@ function toolPolicy(toolName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runtimeImplementation(): string {
|
function runtimeImplementation(): string {
|
||||||
return liveContext ? stringField(liveContext, "runtimeImplementation") || RUNTIME_IMPL : RUNTIME_IMPL;
|
const fromContext = activeContextSnapshot();
|
||||||
|
return fromContext ? stringField(fromContext, "runtimeImplementation") || RUNTIME_IMPL : RUNTIME_IMPL;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestMnoteApprovalViaBridge(request: Record<string, unknown>): Promise<Record<string, unknown>> {
|
async function requestMnoteApprovalViaBridge(request: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||||
const response = await fetch(`${BASE_URL}/api/page-ai/pi/ui-request-bridge`, {
|
const sessionId = bridgeSessionId();
|
||||||
|
if (!sessionId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
cancelled: true,
|
||||||
|
code: "mnote_pi_bridge_session_id_missing",
|
||||||
|
message: "MNote Pi bridge 缺少有效的 pi_lab sessionId",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await fetch(`${bridgeBaseUrl()}/api/page-ai/pi/ui-request-bridge`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN,
|
"x-mnote-pi-lab-bridge-token": bridgeToken(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ sessionId: SESSION_ID, ...request }),
|
body: JSON.stringify({ sessionId, ...request }),
|
||||||
});
|
});
|
||||||
return await response.json().catch(() => ({ ok: false, cancelled: true, code: "bad_json" }));
|
return await response.json().catch(() => ({ ok: false, cancelled: true, code: "bad_json" }));
|
||||||
}
|
}
|
||||||
@@ -467,11 +778,11 @@ function register(pi: ExtensionAPI, tool: MnoteToolManifestEntry) {
|
|||||||
}
|
}
|
||||||
const nativeResult = executeNativeTool(tool, params);
|
const nativeResult = executeNativeTool(tool, params);
|
||||||
if (nativeResult) return nativeResult;
|
if (nativeResult) return nativeResult;
|
||||||
if (runtimeImplementation() === "pi-rust" && !HTTP_BRIDGE_AVAILABLE) {
|
if (runtimeImplementation() === "pi-rust" && !HTTP_BRIDGE_AVAILABLE && !bridgeToken()) {
|
||||||
return toolResult({
|
return toolResult({
|
||||||
ok: false,
|
ok: false,
|
||||||
code: "mnote_pi_rust_service_bridge_unavailable",
|
code: "mnote_pi_rust_service_bridge_unavailable",
|
||||||
message: `${tool.mnoteName} 仍依赖旧 HTTP bridge;Pi Rust 当前应通过原生文件工具或专用 MCP 扩展调用。`,
|
message: `${tool.mnoteName} 缺少 MNote Pi bridge token;Pi Rust 当前应通过原生文件工具或 MNote 受控 bridge 调用。`,
|
||||||
toolName: tool.mnoteName,
|
toolName: tool.mnoteName,
|
||||||
runtimeImplementation: runtimeImplementation(),
|
runtimeImplementation: runtimeImplementation(),
|
||||||
}, true);
|
}, true);
|
||||||
@@ -487,7 +798,7 @@ async function executeBridgeTool(
|
|||||||
ctx: ExtensionContext | undefined,
|
ctx: ExtensionContext | undefined,
|
||||||
tool: MnoteToolManifestEntry,
|
tool: MnoteToolManifestEntry,
|
||||||
) {
|
) {
|
||||||
const nextParams = { ...((params || {}) as Record<string, unknown>) };
|
const nextParams = contextualToolParams(tool.mnoteName, params);
|
||||||
const approval = await requestMnoteApproval(ctx, String(toolCallId || ""), tool.mnoteName, tool.label, nextParams);
|
const approval = await requestMnoteApproval(ctx, String(toolCallId || ""), tool.mnoteName, tool.label, nextParams);
|
||||||
if (approval && approval.denied) {
|
if (approval && approval.denied) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -62,9 +61,6 @@ function executeMcpRequest(params: {
|
|||||||
tool?: string;
|
tool?: string;
|
||||||
arguments?: Record<string, unknown>;
|
arguments?: Record<string, unknown>;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
if (!existsSync(MCP_CLIENT_PATH)) {
|
|
||||||
throw new Error(`MNote MCP client 不存在: ${MCP_CLIENT_PATH}`);
|
|
||||||
}
|
|
||||||
const requestJson = JSON.stringify({
|
const requestJson = JSON.stringify({
|
||||||
server: params.server,
|
server: params.server,
|
||||||
mode: params.mode,
|
mode: params.mode,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
export type AgentScope = "user" | "project" | "both";
|
export type AgentScope = "user" | "project" | "both";
|
||||||
@@ -97,9 +98,20 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
|
|||||||
|
|
||||||
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
||||||
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
||||||
|
const bundledDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "agents");
|
||||||
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
||||||
|
const bundledAgents = loadAgentsFromDir(bundledDir, "user").map((agent) => ({
|
||||||
|
...agent,
|
||||||
|
model: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
const userAgents =
|
||||||
|
scope === "project"
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
...bundledAgents,
|
||||||
|
...loadAgentsFromDir(userDir, "user"),
|
||||||
|
];
|
||||||
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
||||||
|
|
||||||
const agentMap = new Map<string, AgentConfig>();
|
const agentMap = new Map<string, AgentConfig>();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { spawn } from "node:child_process";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import type { Message } from "@mariozechner/pi-ai";
|
import type { Message } from "@mariozechner/pi-ai";
|
||||||
import { StringEnum } from "@mariozechner/pi-ai";
|
import { StringEnum } from "@mariozechner/pi-ai";
|
||||||
@@ -27,6 +28,22 @@ import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
|
|||||||
const MAX_PARALLEL_TASKS = 8;
|
const MAX_PARALLEL_TASKS = 8;
|
||||||
const MAX_CONCURRENCY = 4;
|
const MAX_CONCURRENCY = 4;
|
||||||
const COLLAPSED_ITEM_COUNT = 10;
|
const COLLAPSED_ITEM_COUNT = 10;
|
||||||
|
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const MNOTE_CONTEXT_PATH = path.join(path.dirname(EXTENSION_DIR), "mnote-bridge", "mnote-context.json");
|
||||||
|
|
||||||
|
function readMnoteParentModel(): { provider?: string; id?: string } | undefined {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fs.readFileSync(MNOTE_CONTEXT_PATH, "utf-8")) as {
|
||||||
|
modelProvider?: unknown;
|
||||||
|
modelId?: unknown;
|
||||||
|
};
|
||||||
|
const provider = typeof payload.modelProvider === "string" ? payload.modelProvider.trim() : "";
|
||||||
|
const id = typeof payload.modelId === "string" ? payload.modelId.trim() : "";
|
||||||
|
return provider || id ? { provider: provider || undefined, id: id || undefined } : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTokens(count: number): string {
|
function formatTokens(count: number): string {
|
||||||
if (count < 1000) return count.toString();
|
if (count < 1000) return count.toString();
|
||||||
@@ -227,6 +244,7 @@ async function runSingleAgent(
|
|||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
onUpdate: OnUpdateCallback | undefined,
|
onUpdate: OnUpdateCallback | undefined,
|
||||||
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
||||||
|
parentModel: { provider?: string; id?: string } | undefined,
|
||||||
): Promise<SingleResult> {
|
): Promise<SingleResult> {
|
||||||
const agent = agents.find((a) => a.name === agentName);
|
const agent = agents.find((a) => a.name === agentName);
|
||||||
|
|
||||||
@@ -244,7 +262,12 @@ async function runSingleAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
||||||
if (agent.model) args.push("--model", agent.model);
|
if (agent.model) {
|
||||||
|
args.push("--model", agent.model);
|
||||||
|
} else {
|
||||||
|
if (parentModel?.provider) args.push("--provider", parentModel.provider);
|
||||||
|
if (parentModel?.id) args.push("--model", parentModel.id);
|
||||||
|
}
|
||||||
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
||||||
|
|
||||||
let tmpPromptDir: string | null = null;
|
let tmpPromptDir: string | null = null;
|
||||||
@@ -258,7 +281,7 @@ async function runSingleAgent(
|
|||||||
messages: [],
|
messages: [],
|
||||||
stderr: "",
|
stderr: "",
|
||||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
||||||
model: agent.model,
|
model: agent.model ?? (parentModel?.provider && parentModel.id ? `${parentModel.provider}/${parentModel.id}` : undefined),
|
||||||
step,
|
step,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -411,12 +434,17 @@ export default function (pi: ExtensionAPI) {
|
|||||||
description: [
|
description: [
|
||||||
"Delegate tasks to specialized subagents with isolated context.",
|
"Delegate tasks to specialized subagents with isolated context.",
|
||||||
"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
|
"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
|
||||||
'Default agent scope is "user" (from ~/.pi/agent/agents).',
|
'Default agent scope is "user" (bundled agents plus ~/.pi/agent/agents overrides).',
|
||||||
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
|
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
|
||||||
].join(" "),
|
].join(" "),
|
||||||
parameters: SubagentParams,
|
parameters: SubagentParams,
|
||||||
|
|
||||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
||||||
|
const mnoteModel = readMnoteParentModel();
|
||||||
|
const parentModel = {
|
||||||
|
provider: mnoteModel?.provider ?? process.env.MNOTE_PI_MODEL_PROVIDER ?? ctx.model?.provider,
|
||||||
|
id: mnoteModel?.id ?? process.env.MNOTE_PI_MODEL_ID ?? ctx.model?.id,
|
||||||
|
};
|
||||||
const agentScope: AgentScope = params.agentScope ?? "user";
|
const agentScope: AgentScope = params.agentScope ?? "user";
|
||||||
const discovery = discoverAgents(ctx.cwd, agentScope);
|
const discovery = discoverAgents(ctx.cwd, agentScope);
|
||||||
const agents = discovery.agents;
|
const agents = discovery.agents;
|
||||||
@@ -507,6 +535,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
signal,
|
signal,
|
||||||
chainUpdate,
|
chainUpdate,
|
||||||
makeDetails("chain"),
|
makeDetails("chain"),
|
||||||
|
parentModel,
|
||||||
);
|
);
|
||||||
results.push(result);
|
results.push(result);
|
||||||
|
|
||||||
@@ -587,6 +616,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
makeDetails("parallel"),
|
makeDetails("parallel"),
|
||||||
|
parentModel,
|
||||||
);
|
);
|
||||||
allResults[index] = result;
|
allResults[index] = result;
|
||||||
emitParallelUpdate();
|
emitParallelUpdate();
|
||||||
@@ -621,6 +651,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
signal,
|
signal,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
makeDetails("single"),
|
makeDetails("single"),
|
||||||
|
parentModel,
|
||||||
);
|
);
|
||||||
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
||||||
if (isError) {
|
if (isError) {
|
||||||
|
|||||||
@@ -2622,10 +2622,14 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
|||||||
let conn = self.lock_conn()?;
|
let conn = self.lock_conn()?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at
|
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at
|
||||||
|
FROM (
|
||||||
|
SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at, rowid AS event_rowid
|
||||||
FROM ai_runtime_events
|
FROM ai_runtime_events
|
||||||
WHERE user_id = ?1 AND run_id = ?2
|
WHERE user_id = ?1 AND run_id = ?2
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at DESC, rowid DESC
|
||||||
LIMIT ?3",
|
LIMIT ?3
|
||||||
|
)
|
||||||
|
ORDER BY created_at ASC, event_rowid ASC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(
|
.query_map(
|
||||||
@@ -4225,6 +4229,70 @@ mod tests {
|
|||||||
.is_empty());
|
.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_runtime_events_preserve_insert_order_with_same_timestamp() {
|
||||||
|
let store = store();
|
||||||
|
create_user(&store, "ai_runtime_order_user");
|
||||||
|
store
|
||||||
|
.upsert_ai_runtime_run(UpsertAiRuntimeRunInput {
|
||||||
|
id: None,
|
||||||
|
user_id: "ai_runtime_order_user".to_string(),
|
||||||
|
workspace_id: Some("ws_order".to_string()),
|
||||||
|
document_id: Some("doc_order".to_string()),
|
||||||
|
session_id: "sess_order".to_string(),
|
||||||
|
run_id: "run_order".to_string(),
|
||||||
|
title: Some("order test".to_string()),
|
||||||
|
profile: "pi_lab".to_string(),
|
||||||
|
acp_runtime: "pi".to_string(),
|
||||||
|
trace_id: None,
|
||||||
|
status: "running".to_string(),
|
||||||
|
runtime_json: "{}".to_string(),
|
||||||
|
payload_json: "{}".to_string(),
|
||||||
|
})
|
||||||
|
.expect("insert runtime run");
|
||||||
|
for (id, payload) in [
|
||||||
|
("are_z_first", "{\"seq\":1}"),
|
||||||
|
("are_m_second", "{\"seq\":2}"),
|
||||||
|
("are_a_third", "{\"seq\":3}"),
|
||||||
|
] {
|
||||||
|
store
|
||||||
|
.append_ai_runtime_event(AppendAiRuntimeEventInput {
|
||||||
|
id: Some(id.to_string()),
|
||||||
|
user_id: "ai_runtime_order_user".to_string(),
|
||||||
|
workspace_id: Some("ws_order".to_string()),
|
||||||
|
document_id: Some("doc_order".to_string()),
|
||||||
|
session_id: "sess_order".to_string(),
|
||||||
|
run_id: "run_order".to_string(),
|
||||||
|
profile: "pi_lab".to_string(),
|
||||||
|
acp_runtime: "pi".to_string(),
|
||||||
|
event_type: "pi_rpc_event".to_string(),
|
||||||
|
payload_json: payload.to_string(),
|
||||||
|
})
|
||||||
|
.expect("append ordered runtime event");
|
||||||
|
}
|
||||||
|
|
||||||
|
let events = store
|
||||||
|
.list_ai_runtime_events("ai_runtime_order_user", "run_order", 10)
|
||||||
|
.expect("list runtime events");
|
||||||
|
assert_eq!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.map(|event| event.id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["are_z_first", "are_m_second", "are_a_third"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let tail = store
|
||||||
|
.list_ai_runtime_events("ai_runtime_order_user", "run_order", 2)
|
||||||
|
.expect("list runtime tail events");
|
||||||
|
assert_eq!(
|
||||||
|
tail.iter()
|
||||||
|
.map(|event| event.id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["are_m_second", "are_a_third"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ai_runtime_session_management_renames_auto_titles_and_soft_deletes() {
|
fn ai_runtime_session_management_renames_auto_titles_and_soft_deletes() {
|
||||||
let store = store();
|
let store = store();
|
||||||
|
|||||||
@@ -3146,10 +3146,14 @@ impl ControlPlaneStore for TursoControlPlaneStore {
|
|||||||
let conn = self.lock_conn()?;
|
let conn = self.lock_conn()?;
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at
|
"SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at
|
||||||
|
FROM (
|
||||||
|
SELECT id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at
|
||||||
FROM ai_runtime_events
|
FROM ai_runtime_events
|
||||||
WHERE user_id = ?1 AND run_id = ?2
|
WHERE user_id = ?1 AND run_id = ?2
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT ?3",
|
LIMIT ?3
|
||||||
|
)
|
||||||
|
ORDER BY created_at ASC, id ASC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(
|
.query_map(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ pub async fn status(
|
|||||||
Query(body),
|
Query(body),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(payload)
|
Ok(compact_status_result_for_agent(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
@@ -235,18 +235,17 @@ fn ensure_weknora_scope(
|
|||||||
.with_context(context))
|
.with_context(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact_query_result_for_agent(payload: Value) -> Value {
|
pub(crate) fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||||
let references = payload
|
let payload_references = payload
|
||||||
.get("references")
|
.get("references")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.map(|items| {
|
.cloned()
|
||||||
items
|
.unwrap_or_default();
|
||||||
|
let references = payload_references
|
||||||
.iter()
|
.iter()
|
||||||
.take(8)
|
.take(8)
|
||||||
.map(compact_reference_for_agent)
|
.map(compact_reference_for_agent)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>();
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
let payload_citations = payload
|
let payload_citations = payload
|
||||||
.get("citations")
|
.get("citations")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
@@ -255,16 +254,33 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
|||||||
let citations = if payload_citations.is_empty() {
|
let citations = if payload_citations.is_empty() {
|
||||||
citation_references_for_ui(&references)
|
citation_references_for_ui(&references)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.take(8)
|
||||||
.map(compact_citation_for_agent)
|
.map(compact_citation_for_agent)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
} else {
|
} else {
|
||||||
citation_values_for_ui(&payload_citations)
|
citation_values_for_ui(&payload_citations)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.take(8)
|
||||||
.map(compact_citation_for_agent)
|
.map(compact_citation_for_agent)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
let citation_markdowns = citations
|
let ui_citations = if payload_citations.is_empty() {
|
||||||
|
citation_references_for_ui(&payload_references)
|
||||||
|
} else {
|
||||||
|
citation_values_for_ui(&payload_citations)
|
||||||
|
}
|
||||||
|
.into_iter()
|
||||||
|
.take(8)
|
||||||
|
.map(compact_ui_citation_for_agent)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let citation_count = if payload_citations.is_empty() {
|
||||||
|
payload_references.len()
|
||||||
|
} else {
|
||||||
|
payload_citations.len()
|
||||||
|
};
|
||||||
|
let citation_markdowns = ui_citations
|
||||||
.iter()
|
.iter()
|
||||||
|
.take(2)
|
||||||
.filter_map(|citation| {
|
.filter_map(|citation| {
|
||||||
citation
|
citation
|
||||||
.get("citationMarkdown")
|
.get("citationMarkdown")
|
||||||
@@ -273,7 +289,6 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let ui_citations = citations.clone();
|
|
||||||
let document_structure_index = payload
|
let document_structure_index = payload
|
||||||
.get("documentStructureIndex")
|
.get("documentStructureIndex")
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -285,8 +300,13 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
|||||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||||
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
|
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
|
||||||
"references": references,
|
"references": references,
|
||||||
|
"referenceCount": payload_references.len(),
|
||||||
|
"referencesTruncated": payload_references.len() > 8,
|
||||||
"citations": citations,
|
"citations": citations,
|
||||||
|
"citationCount": citation_count,
|
||||||
|
"citationsTruncated": citation_count > 8,
|
||||||
"citationMarkdowns": citation_markdowns,
|
"citationMarkdowns": citation_markdowns,
|
||||||
|
"citationMarkdownsTruncated": ui_citations.len() > 2,
|
||||||
"uiCitations": ui_citations,
|
"uiCitations": ui_citations,
|
||||||
"documentStructureIndex": compact_document_structure_index_for_agent(&document_structure_index),
|
"documentStructureIndex": compact_document_structure_index_for_agent(&document_structure_index),
|
||||||
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
|
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
|
||||||
@@ -303,6 +323,177 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compact_status_result_for_agent(payload: Value) -> Value {
|
||||||
|
let document_values = payload
|
||||||
|
.pointer("/documents/documents")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let documents = document_values
|
||||||
|
.iter()
|
||||||
|
.take(12)
|
||||||
|
.map(|document| {
|
||||||
|
json!({
|
||||||
|
"id": document.get("id").cloned().unwrap_or(Value::Null),
|
||||||
|
"filePath": document.get("filePath").cloned().unwrap_or(Value::Null),
|
||||||
|
"status": document.get("status").cloned().unwrap_or(Value::Null),
|
||||||
|
"statusGroup": document.get("statusGroup").cloned().unwrap_or(Value::Null),
|
||||||
|
"summary": compact_text_value(document.get("summary"), 240),
|
||||||
|
"chunksCount": document.get("chunksCount").cloned().unwrap_or(Value::Null),
|
||||||
|
"updatedAt": document.get("updatedAt").cloned().unwrap_or(Value::Null),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let registry_entries = payload
|
||||||
|
.pointer("/registry/entries")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let sources = registry_entries
|
||||||
|
.iter()
|
||||||
|
.take(16)
|
||||||
|
.map(|source| {
|
||||||
|
json!({
|
||||||
|
"sourceId": source.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||||
|
"sourceRootRelativePath": source.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||||
|
"sourceKind": source.get("sourceKind").cloned().unwrap_or(Value::Null),
|
||||||
|
"status": source.get("status").cloned().unwrap_or(Value::Null),
|
||||||
|
"provider": source.get("provider").cloned().unwrap_or(Value::Null),
|
||||||
|
"providerStatus": source.get("providerStatus").cloned().unwrap_or(Value::Null),
|
||||||
|
"lightRagStatus": source.get("lightRagStatus").cloned().unwrap_or(Value::Null),
|
||||||
|
"stale": source.get("stale").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"retryRequired": source.get("retryRequired").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"lastError": compact_text_value(source.get("lastError"), 320),
|
||||||
|
"updatedAtMs": source.get("updatedAtMs").cloned().unwrap_or(Value::Null),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let indexed_roots = payload
|
||||||
|
.pointer("/registry/indexedRoots")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.take(12)
|
||||||
|
.map(|root| {
|
||||||
|
json!({
|
||||||
|
"rootRelativePath": root.get("rootRelativePath").cloned().unwrap_or(Value::Null),
|
||||||
|
"recursive": root.get("recursive").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"runOnChange": root.get("runOnChange").cloned().unwrap_or(Value::Null),
|
||||||
|
"updatedAtMs": root.get("updatedAtMs").cloned().unwrap_or(Value::Null),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let knowledge_base_values = payload
|
||||||
|
.get("knowledgeBases")
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.get("bases")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.or_else(|| value.as_array())
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let knowledge_bases = knowledge_base_values
|
||||||
|
.iter()
|
||||||
|
.take(12)
|
||||||
|
.map(|base| {
|
||||||
|
json!({
|
||||||
|
"baseId": base.get("baseId").cloned().unwrap_or(Value::Null),
|
||||||
|
"name": base.get("name").cloned().unwrap_or(Value::Null),
|
||||||
|
"provider": base.get("provider").cloned().unwrap_or(Value::Null),
|
||||||
|
"providerKbId": base.get("providerKbId").cloned().unwrap_or(Value::Null),
|
||||||
|
"defaultToolEnabled": base.get("defaultToolEnabled").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"canWrite": base.get("canWrite").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"sourceCount": base.get("sourceCount").cloned().unwrap_or(Value::Null),
|
||||||
|
"chunkCount": base.get("chunkCount").cloned().unwrap_or(Value::Null),
|
||||||
|
"status": base.get("status").cloned().unwrap_or(Value::Null),
|
||||||
|
"updatedAtMs": base.get("updatedAtMs").cloned().unwrap_or(Value::Null),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let pipeline = payload.get("pipeline").unwrap_or(&Value::Null);
|
||||||
|
let rerank = payload.get("rerank").unwrap_or(&Value::Null);
|
||||||
|
json!({
|
||||||
|
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||||
|
"schema": "mnote.knowledge_rag.agent_status_result.v1",
|
||||||
|
"provider": payload.get("provider").cloned().unwrap_or(Value::Null),
|
||||||
|
"endpoint": payload.get("endpoint").cloned().unwrap_or(Value::Null),
|
||||||
|
"health": compact_health_for_agent(payload.get("health")),
|
||||||
|
"rerank": {
|
||||||
|
"enabled": rerank.get("enabled").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"available": rerank.get("available").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"status": rerank.get("status").cloned().unwrap_or(Value::Null),
|
||||||
|
"binding": rerank.get("binding").cloned().unwrap_or(Value::Null),
|
||||||
|
"model": rerank.get("model").or_else(|| rerank.get("rerankModel")).cloned().unwrap_or(Value::Null),
|
||||||
|
"minScore": rerank.get("minScore").cloned().unwrap_or(Value::Null),
|
||||||
|
},
|
||||||
|
"documents": {
|
||||||
|
"ok": payload.pointer("/documents/ok").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"total": document_values.len(),
|
||||||
|
"statusGroups": payload.pointer("/documents/rawStatusGroups").cloned().unwrap_or_else(|| json!({})),
|
||||||
|
"items": documents,
|
||||||
|
"truncated": document_values.len() > 12,
|
||||||
|
},
|
||||||
|
"pipeline": {
|
||||||
|
"ok": pipeline.get("ok").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"busy": pipeline.get("busy").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"scanning": pipeline.get("scanning").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"requestPending": pipeline.get("requestPending").cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"pendingEnqueues": pipeline.get("pendingEnqueues").cloned().unwrap_or(Value::Null),
|
||||||
|
"docs": pipeline.get("docs").cloned().unwrap_or(Value::Null),
|
||||||
|
"batches": pipeline.get("batches").cloned().unwrap_or(Value::Null),
|
||||||
|
"currentBatch": pipeline.get("currentBatch").cloned().unwrap_or(Value::Null),
|
||||||
|
"jobName": pipeline.get("jobName").cloned().unwrap_or(Value::Null),
|
||||||
|
"latestMessage": compact_text_value(pipeline.get("latestMessage"), 400),
|
||||||
|
"progress": pipeline.get("progress").cloned().unwrap_or(Value::Null),
|
||||||
|
},
|
||||||
|
"sourceRegistry": {
|
||||||
|
"workspaceId": payload.pointer("/registry/workspaceId").cloned().unwrap_or(Value::Null),
|
||||||
|
"rootUri": payload.pointer("/registry/rootUri").cloned().unwrap_or(Value::Null),
|
||||||
|
"updatedAtMs": payload.pointer("/registry/updatedAtMs").cloned().unwrap_or(Value::Null),
|
||||||
|
"sourceCount": registry_entries.len(),
|
||||||
|
"indexedRootCount": payload.pointer("/registry/indexedRoots").and_then(Value::as_array).map(Vec::len).unwrap_or(0),
|
||||||
|
"indexedRoots": indexed_roots,
|
||||||
|
"sources": sources,
|
||||||
|
"truncated": registry_entries.len() > 16,
|
||||||
|
},
|
||||||
|
"knowledgeBases": {
|
||||||
|
"total": knowledge_base_values.len(),
|
||||||
|
"items": knowledge_bases,
|
||||||
|
"truncated": knowledge_base_values.len() > 12,
|
||||||
|
},
|
||||||
|
"agentGuidance": "This is a compact provider/status summary for the agent. The full provider configuration, raw health payload, registry providerResponse, and complete document lists remain available to the MNote UI/API and are intentionally omitted from the model context.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_health_for_agent(value: Option<&Value>) -> Value {
|
||||||
|
let nested = value.and_then(|health| health.get("health"));
|
||||||
|
json!({
|
||||||
|
"ok": value.and_then(|health| health.get("ok")).cloned().unwrap_or(Value::Bool(false)),
|
||||||
|
"status": nested.and_then(|health| health.get("status")).or_else(|| value.and_then(|health| health.get("status"))).cloned().unwrap_or(Value::Null),
|
||||||
|
"healthy": nested.and_then(|health| health.get("healthy")).or_else(|| value.and_then(|health| health.get("healthy"))).cloned().unwrap_or(Value::Null),
|
||||||
|
"provider": nested.and_then(|health| health.get("provider")).or_else(|| value.and_then(|health| health.get("provider"))).cloned().unwrap_or(Value::Null),
|
||||||
|
"version": nested.and_then(|health| health.get("version")).or_else(|| value.and_then(|health| health.get("version"))).cloned().unwrap_or(Value::Null),
|
||||||
|
"code": value.and_then(|health| health.get("code")).cloned().unwrap_or(Value::Null),
|
||||||
|
"message": compact_text_value(value.and_then(|health| health.get("message")), 320),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_text_value(value: Option<&Value>, max_chars: usize) -> Value {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|text| text.chars().take(max_chars).collect::<String>())
|
||||||
|
.map(Value::String)
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
fn compact_document_structure_index_for_agent(value: &Value) -> Value {
|
fn compact_document_structure_index_for_agent(value: &Value) -> Value {
|
||||||
if value.is_null() {
|
if value.is_null() {
|
||||||
return Value::Null;
|
return Value::Null;
|
||||||
@@ -393,7 +584,7 @@ fn compact_structure_section_for_agent(section: &Value) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact_section_context_for_agent(payload: Value) -> Value {
|
pub(crate) fn compact_section_context_for_agent(payload: Value) -> Value {
|
||||||
let blocks = payload
|
let blocks = payload
|
||||||
.get("blocks")
|
.get("blocks")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
@@ -470,12 +661,7 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
|||||||
.get("displayQuote")
|
.get("displayQuote")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.or_else(|| reference.get("quote").and_then(Value::as_str))
|
.or_else(|| reference.get("quote").and_then(Value::as_str))
|
||||||
.map(|value| value.chars().take(700).collect::<String>())
|
.map(|value| value.chars().take(560).collect::<String>())
|
||||||
.unwrap_or_default();
|
|
||||||
let locator_evidence_text = reference
|
|
||||||
.get("locatorEvidenceText")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|value| value.chars().take(700).collect::<String>())
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let quote_diagnostics = reference
|
let quote_diagnostics = reference
|
||||||
.get("contentDiagnostics")
|
.get("contentDiagnostics")
|
||||||
@@ -490,16 +676,11 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
|||||||
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
"sourceRootRelativePath": reference.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||||
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
|
"chunkId": reference.get("chunkId").cloned().unwrap_or(Value::Null),
|
||||||
"quote": quote,
|
"quote": quote,
|
||||||
"displayQuote": reference.get("displayQuote").cloned().unwrap_or(Value::Null),
|
|
||||||
"locatorEvidenceText": locator_evidence_text,
|
|
||||||
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
|
"quoteSource": reference.get("quoteSource").cloned().unwrap_or(Value::Null),
|
||||||
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
|
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
|
||||||
"locatorPrecision": reference.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
"locatorPrecision": reference.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||||
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||||
"contentDiagnostics": quote_diagnostics,
|
"contentDiagnostics": quote_diagnostics,
|
||||||
"citationDiagnostics": reference.get("citationDiagnostics").cloned().unwrap_or(Value::Null),
|
|
||||||
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
|
||||||
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,12 +689,7 @@ fn compact_citation_for_agent(citation: &Value) -> Value {
|
|||||||
.get("displayQuote")
|
.get("displayQuote")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.or_else(|| citation.get("quote").and_then(Value::as_str))
|
.or_else(|| citation.get("quote").and_then(Value::as_str))
|
||||||
.map(|value| value.chars().take(420).collect::<String>())
|
.map(|value| value.chars().take(360).collect::<String>())
|
||||||
.unwrap_or_default();
|
|
||||||
let locator_evidence_text = citation
|
|
||||||
.get("locatorEvidenceText")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(|value| value.chars().take(420).collect::<String>())
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
json!({
|
json!({
|
||||||
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
||||||
@@ -521,21 +697,25 @@ fn compact_citation_for_agent(citation: &Value) -> Value {
|
|||||||
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
|
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
|
||||||
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
|
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||||
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
|
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||||
"sourcePath": citation.get("sourcePath").cloned().unwrap_or(Value::Null),
|
|
||||||
"sourceRootRelativePath": citation.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
"sourceRootRelativePath": citation.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||||
"filePath": citation.get("filePath").or_else(|| citation.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
|
|
||||||
"chunkId": citation.get("chunkId").or_else(|| citation.get("lightRagChunkId")).cloned().unwrap_or(Value::Null),
|
"chunkId": citation.get("chunkId").or_else(|| citation.get("lightRagChunkId")).cloned().unwrap_or(Value::Null),
|
||||||
"blockId": citation.get("blockId").cloned().unwrap_or(Value::Null),
|
"blockId": citation.get("blockId").cloned().unwrap_or(Value::Null),
|
||||||
"headingPath": citation.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
"headingPath": citation.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||||
"quote": display_quote,
|
"quote": display_quote,
|
||||||
"displayQuote": citation.get("displayQuote").cloned().unwrap_or(Value::Null),
|
"locatorPrecision": citation.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||||
"locatorEvidenceText": locator_evidence_text,
|
"locatorDegraded": citation.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||||
"searchQuery": citation.get("searchQuery").cloned().unwrap_or(Value::Null),
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_ui_citation_for_agent(citation: &Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
|
||||||
|
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||||
|
"sourceRootRelativePath": citation.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||||
|
"headingPath": citation.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||||
"locatorPrecision": citation.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
"locatorPrecision": citation.get("locatorPrecision").cloned().unwrap_or(Value::Null),
|
||||||
"locatorDegraded": citation.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
"locatorDegraded": citation.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
|
||||||
"citationMarkdown": citation.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
"citationMarkdown": citation.get("citationMarkdown").cloned().unwrap_or(Value::Null),
|
||||||
"citationUrl": citation.get("citationUrl").cloned().unwrap_or(Value::Null),
|
|
||||||
"diagnostics": citation.get("diagnostics").or_else(|| citation.get("citationDiagnostics")).cloned().unwrap_or(Value::Null),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,8 +849,9 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(compact["citations"][0]["citationId"], "c0de");
|
assert_eq!(compact["citations"][0]["citationId"], "c0de");
|
||||||
assert_eq!(compact["citations"][0]["headingPath"][0], "保护基");
|
assert_eq!(compact["citations"][0]["headingPath"][0], "保护基");
|
||||||
assert_eq!(compact["citations"][0]["displayQuote"], "吡咯烷,5 h,90%");
|
assert_eq!(compact["citations"][0]["quote"], "吡咯烷,5 h,90%");
|
||||||
assert!(compact["citations"][0].get("rawQuote").is_none());
|
assert!(compact["citations"][0].get("rawQuote").is_none());
|
||||||
|
assert!(compact["citations"][0].get("citationMarkdown").is_none());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
compact["uiCitations"][0]["citationMarkdown"].as_str(),
|
compact["uiCitations"][0]["citationMarkdown"].as_str(),
|
||||||
Some("[docs/a.docx](/documents/a)")
|
Some("[docs/a.docx](/documents/a)")
|
||||||
@@ -701,4 +882,132 @@ mod tests {
|
|||||||
Some("chunk")
|
Some("chunk")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_status_result_keeps_capability_summary_without_large_payloads() {
|
||||||
|
let documents = (0..20)
|
||||||
|
.map(|index| {
|
||||||
|
json!({
|
||||||
|
"id": format!("doc-{index}"),
|
||||||
|
"filePath": format!("docs/{index}.md"),
|
||||||
|
"status": "processed",
|
||||||
|
"statusGroup": "processed",
|
||||||
|
"summary": "x".repeat(2_000),
|
||||||
|
"chunksCount": 10,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let sources = (0..24)
|
||||||
|
.map(|index| {
|
||||||
|
json!({
|
||||||
|
"sourceId": format!("source-{index}"),
|
||||||
|
"sourceRootRelativePath": format!("docs/{index}.md"),
|
||||||
|
"providerStatus": "indexed",
|
||||||
|
"providerResponse": {"raw": "y".repeat(4_000)},
|
||||||
|
"lastError": "z".repeat(1_000),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let payload = json!({
|
||||||
|
"ok": true,
|
||||||
|
"provider": "lightrag",
|
||||||
|
"providerConfig": {"apiKey": "must-not-reach-agent"},
|
||||||
|
"endpoint": "http://127.0.0.1:9621",
|
||||||
|
"health": {"ok": true, "health": {"status": "healthy", "raw": "h".repeat(8_000)}},
|
||||||
|
"rerank": {"enabled": true, "available": true, "status": "ready", "model": "reranker"},
|
||||||
|
"documents": {
|
||||||
|
"ok": true,
|
||||||
|
"rawStatusGroups": {"processed": 20},
|
||||||
|
"documents": documents,
|
||||||
|
},
|
||||||
|
"pipeline": {
|
||||||
|
"ok": true,
|
||||||
|
"busy": false,
|
||||||
|
"historyMessages": vec!["history".repeat(1_000); 100],
|
||||||
|
"latestMessage": "latest".repeat(500),
|
||||||
|
},
|
||||||
|
"registry": {
|
||||||
|
"workspaceId": "workspace",
|
||||||
|
"rootUri": "file:///workspace",
|
||||||
|
"indexedRoots": [{"rootRelativePath": "docs", "recursive": true}],
|
||||||
|
"entries": sources,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let compact = compact_status_result_for_agent(payload);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
compact["schema"],
|
||||||
|
"mnote.knowledge_rag.agent_status_result.v1"
|
||||||
|
);
|
||||||
|
assert_eq!(compact["documents"]["total"], 20);
|
||||||
|
assert_eq!(compact["documents"]["items"].as_array().unwrap().len(), 12);
|
||||||
|
assert_eq!(compact["sourceRegistry"]["sourceCount"], 24);
|
||||||
|
assert_eq!(
|
||||||
|
compact["sourceRegistry"]["sources"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
16
|
||||||
|
);
|
||||||
|
assert!(compact.get("providerConfig").is_none());
|
||||||
|
assert!(compact["health"].get("raw").is_none());
|
||||||
|
assert!(compact["sourceRegistry"]["sources"][0]
|
||||||
|
.get("providerResponse")
|
||||||
|
.is_none());
|
||||||
|
assert!(
|
||||||
|
serde_json::to_vec(&compact)
|
||||||
|
.expect("serialize compact status")
|
||||||
|
.len()
|
||||||
|
< 40_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_query_result_caps_repeated_evidence_and_ui_locators() {
|
||||||
|
let references = (0..40)
|
||||||
|
.map(|index| {
|
||||||
|
json!({
|
||||||
|
"citationId": format!("ref-{index}"),
|
||||||
|
"sourceRootRelativePath": format!("docs/{index}.md"),
|
||||||
|
"displayQuote": "evidence".repeat(500),
|
||||||
|
"locatorEvidenceText": "duplicate evidence".repeat(500),
|
||||||
|
"citationMarkdown": format!("[docs/{index}.md](/documents/{})", "x".repeat(4_000)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let citations = (0..40)
|
||||||
|
.map(|index| {
|
||||||
|
json!({
|
||||||
|
"citationId": format!("citation-{index}"),
|
||||||
|
"citationLabel": format!("[{index}]"),
|
||||||
|
"sourceRootRelativePath": format!("docs/{index}.md"),
|
||||||
|
"displayQuote": "citation evidence".repeat(500),
|
||||||
|
"sourcePath": format!("/very/long/{}", "path".repeat(1_000)),
|
||||||
|
"citationMarkdown": format!("[docs/{index}.md](/documents/{})", "y".repeat(4_000)),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let compact = compact_query_result_for_agent(json!({
|
||||||
|
"references": references,
|
||||||
|
"citations": citations,
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(compact["references"].as_array().unwrap().len(), 8);
|
||||||
|
assert_eq!(compact["citations"].as_array().unwrap().len(), 8);
|
||||||
|
assert_eq!(compact["uiCitations"].as_array().unwrap().len(), 8);
|
||||||
|
assert_eq!(compact["referenceCount"], 40);
|
||||||
|
assert_eq!(compact["citationCount"], 40);
|
||||||
|
assert_eq!(compact["referencesTruncated"], true);
|
||||||
|
assert_eq!(compact["citationsTruncated"], true);
|
||||||
|
assert!(compact["references"][0].get("citationMarkdown").is_none());
|
||||||
|
assert!(compact["citations"][0].get("citationMarkdown").is_none());
|
||||||
|
assert!(
|
||||||
|
serde_json::to_vec(&compact)
|
||||||
|
.expect("serialize compact query")
|
||||||
|
.len()
|
||||||
|
< 60_000
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1177,7 +1177,8 @@ pub async fn admin_put_user_settings(
|
|||||||
|
|
||||||
// ─── Constants ──────────────────────────────────────────────────────────
|
// ─── Constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_PI_MODEL: &str = "omniroute/freefirst";
|
const DEFAULT_PI_MODEL: &str = "omniroute/gpt-5.4-mini";
|
||||||
|
const FREEFIRST_PI_MODEL: &str = "omniroute/freefirst";
|
||||||
const LIGHTRAG_PROVIDER: &str = "lightrag";
|
const LIGHTRAG_PROVIDER: &str = "lightrag";
|
||||||
const LIGHTRAG_PROVIDER_DESCRIPTION: &str = "MNote 知识库默认提供者(LightRAG)";
|
const LIGHTRAG_PROVIDER_DESCRIPTION: &str = "MNote 知识库默认提供者(LightRAG)";
|
||||||
const SOURCE_OF_TRUTH: &str = "directory_grants";
|
const SOURCE_OF_TRUTH: &str = "directory_grants";
|
||||||
@@ -1904,6 +1905,12 @@ pub(crate) fn load_effective_ai_runtime_policy(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|entry| normalize_model_ref(Some(&default_provider), &entry.id))
|
.filter_map(|entry| normalize_model_ref(Some(&default_provider), &entry.id))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
if !allowed_models.iter().any(|model| model == FREEFIRST_PI_MODEL) {
|
||||||
|
allowed_models.push(FREEFIRST_PI_MODEL.to_string());
|
||||||
|
}
|
||||||
|
if !allowed_models.iter().any(|model| model == DEFAULT_PI_MODEL) {
|
||||||
|
allowed_models.push(DEFAULT_PI_MODEL.to_string());
|
||||||
|
}
|
||||||
allowed_models.sort();
|
allowed_models.sort();
|
||||||
allowed_models.dedup();
|
allowed_models.dedup();
|
||||||
|
|
||||||
|
|||||||
@@ -601,6 +601,13 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
|
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
|
||||||
.route("/api/page-ai/pi/send", post(page_ai_pi::send))
|
.route("/api/page-ai/pi/send", post(page_ai_pi::send))
|
||||||
.route("/api/page-ai/pi/abort", post(page_ai_pi::abort))
|
.route("/api/page-ai/pi/abort", post(page_ai_pi::abort))
|
||||||
|
.route("/api/page-ai/pi/configure", post(page_ai_pi::configure))
|
||||||
|
.route("/api/page-ai/pi/state", post(page_ai_pi::state))
|
||||||
|
.route("/api/page-ai/pi/compact", post(page_ai_pi::compact))
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/pi/queue-config",
|
||||||
|
post(page_ai_pi::queue_config),
|
||||||
|
)
|
||||||
.route("/api/page-ai/pi/ui-response", post(page_ai_pi::ui_response))
|
.route("/api/page-ai/pi/ui-response", post(page_ai_pi::ui_response))
|
||||||
.route(
|
.route(
|
||||||
"/api/page-ai/pi/ui-request-bridge",
|
"/api/page-ai/pi/ui-request-bridge",
|
||||||
@@ -631,6 +638,15 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
post(page_ai_pi::mcp_call_bridge),
|
post(page_ai_pi::mcp_call_bridge),
|
||||||
)
|
)
|
||||||
.route("/page-ai/pi", get(page_ai_pi::shell))
|
.route("/page-ai/pi", get(page_ai_pi::shell))
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/pi/sessions/{session_id}/tree",
|
||||||
|
get(page_ai_pi::session_tree),
|
||||||
|
)
|
||||||
|
.route("/api/page-ai/pi/fork", post(page_ai_pi::fork_pi_session))
|
||||||
|
.route(
|
||||||
|
"/api/page-ai/pi/artifacts/{tool_event_id}/diff",
|
||||||
|
get(page_ai_pi::artifact_diff),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/sidebar/shortcuts",
|
"/api/sidebar/shortcuts",
|
||||||
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
|
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
- 2026-05-20 起,默认主入口固定按 `http://127.0.0.1:3000` 理解;历史记录里的 `3001` 只代表当时临时 mnote-web 实例,不再作为默认验收入口。
|
- 2026-05-20 起,默认主入口固定按 `http://127.0.0.1:3000` 理解;历史记录里的 `3001` 只代表当时临时 mnote-web 实例,不再作为默认验收入口。
|
||||||
- 历史 smoke 分为 `current` 与 `retired/debug` 两类维护;退役脚本不应进入默认回归,除非脚本自身要求显式环境变量。
|
- 历史 smoke 分为 `current` 与 `retired/debug` 两类维护;退役脚本不应进入默认回归,除非脚本自身要求显式环境变量。
|
||||||
- 2026-05-27 起,默认 smoke 基线只覆盖 `3000 Rust SSR + leptos-tiptap + local-first` 主路径;Convex export、Convex 兼容、Next、3104、BlockNote 默认路径脚本不再放在默认候选里。
|
- 2026-05-27 起,默认 smoke 基线只覆盖 `3000 Rust SSR + leptos-tiptap + local-first` 主路径;Convex export、Convex 兼容、Next、3104、BlockNote 默认路径脚本不再放在默认候选里。
|
||||||
|
- 依赖 `/api/dev/seed` 的 browser smoke 默认使用 `npm run dev:hot`;`dev:hot` 默认启用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1`,`desktop:hot` 与生产启动仍默认关闭。修改 Node 启动脚本或环境变量后必须重启 `dev:hot` 主进程,不能只等待 cargo-watch 重载 Rust。
|
||||||
|
|
||||||
## 0. 当前 smoke 状态清单
|
## 0. 当前 smoke 状态清单
|
||||||
|
|
||||||
@@ -152,6 +153,13 @@ node scripts/task490-runtime-surfaces-smoke.js
|
|||||||
|
|
||||||
后续新脚本优先复用这些 helper,不要在每个任务里再复制一套登录和清理逻辑。
|
后续新脚本优先复用这些 helper,不要在每个任务里再复制一套登录和清理逻辑。
|
||||||
|
|
||||||
|
#### Dev seed 启动契约
|
||||||
|
|
||||||
|
- 需要 `setupWorkspaceAccess`、`seedAiPolicy`、`seedAiRuntime` 等测试数据准备能力时,先确认服务由 `npm run dev:hot` 启动。
|
||||||
|
- `npm run dev:hot` 默认开启 dev fixtures;可用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=0 npm run dev:hot` 显式关闭。
|
||||||
|
- `npm run desktop:hot` 保持生产近似的安全默认,不自动开放 `/api/dev/seed`;确需复用时显式执行 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1 npm run desktop:hot`。
|
||||||
|
- smoke 遇到 `dev_seed_disabled` 时不得标记为“功能未验证后跳过”;应先按上述契约重启服务,再重新执行完整 smoke。
|
||||||
|
|
||||||
## 2. 当前推荐测试顺序
|
## 2. 当前推荐测试顺序
|
||||||
|
|
||||||
对本项目,推荐不要一上来就跑最重的 UI 对标脚本,而是按下面顺序推进。
|
对本项目,推荐不要一上来就跑最重的 UI 对标脚本,而是按下面顺序推进。
|
||||||
|
|||||||
+5
-1
@@ -8,6 +8,8 @@
|
|||||||
* - 不改动 desktop:hot 的默认行为。
|
* - 不改动 desktop:hot 的默认行为。
|
||||||
* - 使用 cargo-watch 自动重编译并重启 mnote-web。
|
* - 使用 cargo-watch 自动重编译并重启 mnote-web。
|
||||||
* - 通过 MNOTE_WEB_DEV_HOT_RELOAD 启用页面端轻量 reload 轮询。
|
* - 通过 MNOTE_WEB_DEV_HOT_RELOAD 启用页面端轻量 reload 轮询。
|
||||||
|
* - 默认启用 MNOTE_WEB_ALLOW_DEV_FIXTURES,保证依赖 /api/dev/seed 的 smoke 可直接运行;
|
||||||
|
* 可显式设为 0 关闭,desktop:hot / prod:start 仍保持默认关闭。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { spawn } = require("node:child_process");
|
const { spawn } = require("node:child_process");
|
||||||
@@ -80,6 +82,8 @@ function buildDevHotEnv(baseEnv = process.env) {
|
|||||||
const env = {
|
const env = {
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
MNOTE_WEB_DEV_HOT_RELOAD: "1",
|
MNOTE_WEB_DEV_HOT_RELOAD: "1",
|
||||||
|
MNOTE_WEB_ALLOW_DEV_FIXTURES:
|
||||||
|
String(baseEnv.MNOTE_WEB_ALLOW_DEV_FIXTURES ?? "1").trim() || "1",
|
||||||
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
|
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
|
||||||
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
||||||
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
|
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
|
||||||
@@ -95,7 +99,7 @@ function buildDevHotEnv(baseEnv = process.env) {
|
|||||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: String(baseEnv.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
|
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: String(baseEnv.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
|
||||||
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
|
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
|
||||||
MNOTE_PAGE_AI_PI_WARMUP: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP ?? "1").trim() || "1",
|
MNOTE_PAGE_AI_PI_WARMUP: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP ?? "1").trim() || "1",
|
||||||
MNOTE_PAGE_AI_PI_WARMUP_SEND: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP_SEND ?? "1").trim() || "1",
|
MNOTE_PAGE_AI_PI_WARMUP_SEND: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP_SEND ?? "0").trim() || "0",
|
||||||
};
|
};
|
||||||
if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") {
|
if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") {
|
||||||
env.MNOTE_TURSO_LOCAL_PATH =
|
env.MNOTE_TURSO_LOCAL_PATH =
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
|
|
||||||
async function postDevSeed(requestContext, baseUrl, seeds, timeoutMs) {
|
async function postDevSeed(requestContext, baseUrl, seeds, timeoutMs) {
|
||||||
const response = await requestContext.fetch(`${baseUrl.replace(/\/+$/, "")}/api/dev/seed`, {
|
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
|
||||||
|
const response = await requestContext.fetch(`${normalizedBaseUrl}/api/dev/seed`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
data: { seeds },
|
data: { seeds },
|
||||||
@@ -16,10 +17,20 @@ async function postDevSeed(requestContext, baseUrl, seeds, timeoutMs) {
|
|||||||
} catch {
|
} catch {
|
||||||
payload = text;
|
payload = text;
|
||||||
}
|
}
|
||||||
assert(
|
if (!response.ok()) {
|
||||||
response.ok(),
|
const details = typeof payload === "string" ? payload : JSON.stringify(payload);
|
||||||
`/api/dev/seed 失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
|
if (response.status() === 403 && payload && payload.code === "dev_seed_disabled") {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`/api/dev/seed 未启用(base=${normalizedBaseUrl})。`,
|
||||||
|
"依赖 seed 的 smoke 必须使用 `npm run dev:hot` 启动;dev:hot 默认开启 MNOTE_WEB_ALLOW_DEV_FIXTURES=1。",
|
||||||
|
"若复用 desktop:hot,请使用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1 npm run desktop:hot`。",
|
||||||
|
"修改 scripts/dev-hot.js 或启动环境后必须重启 dev:hot 主进程,cargo-watch 不会刷新 Node 启动环境。",
|
||||||
|
].join(" "),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
assert.fail(`/api/dev/seed 失败: ${response.status()} ${details}`);
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,8 +86,8 @@ async function seedAiRuntime(requestContext, baseUrl, options) {
|
|||||||
payload_json: options.payloadJson || {},
|
payload_json: options.payloadJson || {},
|
||||||
events: (options.events || []).map((event) => ({
|
events: (options.events || []).map((event) => ({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
event_type: event.event_type || event.eventType,
|
eventType: event.eventType || event.event_type,
|
||||||
payload_json: event.payload_json || event.payloadJson || {},
|
payloadJson: event.payloadJson || event.payload_json || {},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const env = buildDevHotEnv({
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(env.MNOTE_WEB_DEV_HOT_RELOAD, "1");
|
assert.equal(env.MNOTE_WEB_DEV_HOT_RELOAD, "1");
|
||||||
|
assert.equal(env.MNOTE_WEB_ALLOW_DEV_FIXTURES, "1");
|
||||||
assert.match(env.MNOTE_WEB_CMD, /cargo watch/);
|
assert.match(env.MNOTE_WEB_CMD, /cargo watch/);
|
||||||
assert.match(env.MNOTE_WEB_CMD, /run -p mnote-web --bin mnote-web/);
|
assert.match(env.MNOTE_WEB_CMD, /run -p mnote-web --bin mnote-web/);
|
||||||
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/src/);
|
assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/src/);
|
||||||
@@ -48,6 +49,11 @@ const skipEnv = buildDevHotEnv({
|
|||||||
});
|
});
|
||||||
assert.equal(skipEnv.ENABLE_OPENHUB, "");
|
assert.equal(skipEnv.ENABLE_OPENHUB, "");
|
||||||
|
|
||||||
|
const fixturesDisabledEnv = buildDevHotEnv({
|
||||||
|
MNOTE_WEB_ALLOW_DEV_FIXTURES: "0",
|
||||||
|
});
|
||||||
|
assert.equal(fixturesDisabledEnv.MNOTE_WEB_ALLOW_DEV_FIXTURES, "0");
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => buildDevHotEnv({
|
() => buildDevHotEnv({
|
||||||
MNOTE_CONTROL_PLANE_BACKEND: "sqlite",
|
MNOTE_CONTROL_PLANE_BACKEND: "sqlite",
|
||||||
|
|||||||
@@ -86,26 +86,37 @@ async function main() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// 5. Send endpoint schema
|
// 5. Send endpoint schema
|
||||||
|
results.push(await check('POST /api/page-ai/pi/configure exists for model/thinking changes (disabled may 404)', async () => {
|
||||||
|
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/configure`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ sessionId: 'test', modelProvider: 'omniroute', modelId: 'freefirst', thinkingLevel: 'off' }),
|
||||||
|
});
|
||||||
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
|
if (body.schema === 'mnote.page_ai_pi.configure.v1' || body.ok === true) return { passed: true };
|
||||||
|
return { passed: false, reason: `unexpected configure response: ${JSON.stringify(body).slice(0, 200)}` };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6. Send endpoint schema
|
||||||
results.push(await check('POST /api/page-ai/pi/send returns proper schema (disabled may 404)', async () => {
|
results.push(await check('POST /api/page-ai/pi/send returns proper schema (disabled may 404)', async () => {
|
||||||
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
|
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ sessionId: 'test', message: 'hello' }),
|
body: JSON.stringify({ sessionId: 'test', message: 'hello' }),
|
||||||
});
|
});
|
||||||
if (status === 404 || status === 401 || status === 403) return { passed: true };
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
return { passed: true }; // routed correctly
|
return { passed: true }; // routed correctly
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 6. Abort endpoint
|
// 7. Abort endpoint
|
||||||
results.push(await check('POST /api/page-ai/pi/abort returns proper response (disabled may 404)', async () => {
|
results.push(await check('POST /api/page-ai/pi/abort returns proper response (disabled may 404)', async () => {
|
||||||
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ sessionId: 'test' }),
|
body: JSON.stringify({ sessionId: 'test' }),
|
||||||
});
|
});
|
||||||
if (status === 404 || status === 401 || status === 403) return { passed: true };
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
return { passed: true };
|
return { passed: true };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 7. Events SSE endpoint returns proper content type
|
// 8. Events SSE endpoint returns proper content type
|
||||||
results.push(await check('GET /api/page-ai/pi/events returns SSE stream (disabled may 404)', async () => {
|
results.push(await check('GET /api/page-ai/pi/events returns SSE stream (disabled may 404)', async () => {
|
||||||
const res = await fetch(`${BASE}/api/page-ai/pi/events`, {
|
const res = await fetch(`${BASE}/api/page-ai/pi/events`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -120,13 +131,13 @@ async function main() {
|
|||||||
return { passed: false, reason: `unexpected Content-Type: ${ct}` };
|
return { passed: false, reason: `unexpected Content-Type: ${ct}` };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 8. Tool call endpoint
|
// 9. Tool call endpoint
|
||||||
results.push(await check('POST /api/page-ai/pi/tool-call exists (disabled may 404)', async () => {
|
results.push(await check('POST /api/page-ai/pi/tool-call exists (disabled may 404)', async () => {
|
||||||
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
|
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ toolName: 'mnote.allowed_roots.describe', params: {} }),
|
body: JSON.stringify({ toolName: 'mnote.allowed_roots.describe', params: {} }),
|
||||||
});
|
});
|
||||||
if (status === 404 || status === 401 || status === 403) return { passed: true };
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
return { passed: true };
|
return { passed: true };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -140,7 +151,48 @@ async function main() {
|
|||||||
return { passed: true }; // accept any response — mounted
|
return { passed: true }; // accept any response — mounted
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 10. Runtime asset exists
|
|
||||||
|
// 11. State endpoint
|
||||||
|
results.push(await check("POST /api/page-ai/pi/state returns proper schema (disabled may 404)", async () => {
|
||||||
|
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/state`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sessionId: "test" }),
|
||||||
|
});
|
||||||
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
|
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||||
|
if (body.schema !== "mnote.page_ai_pi.state.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||||
|
if (typeof body.running !== "boolean") return { passed: false, reason: "missing running boolean" };
|
||||||
|
if (typeof body.pendingMessageCount !== "number") return { passed: false, reason: "missing pendingMessageCount number" };
|
||||||
|
return { passed: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 12. Compact endpoint
|
||||||
|
results.push(await check("POST /api/page-ai/pi/compact returns proper schema (disabled may 404)", async () => {
|
||||||
|
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/compact`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sessionId: "test" }),
|
||||||
|
});
|
||||||
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
|
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||||
|
if (body.schema !== "mnote.page_ai_pi.compact.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||||
|
if (typeof body.summary !== "string") return { passed: false, reason: "missing summary string" };
|
||||||
|
return { passed: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 13. Queue-config endpoint
|
||||||
|
results.push(await check("POST /api/page-ai/pi/queue-config returns proper schema (disabled may 404)", async () => {
|
||||||
|
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/queue-config`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sessionId: "test", steeringMode: "one-at-a-time", followUpMode: "all", autoCompaction: true }),
|
||||||
|
});
|
||||||
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
|
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||||
|
if (body.schema !== "mnote.page_ai_pi.queue_config.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||||
|
if (typeof body.applied !== "object") return { passed: false, reason: "missing applied object" };
|
||||||
|
return { passed: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 14. Runtime asset exists
|
||||||
results.push(await check('GET /api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js returns 200', async () => {
|
results.push(await check('GET /api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js returns 200', async () => {
|
||||||
const res = await fetch(`${BASE}/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js`);
|
const res = await fetch(`${BASE}/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js`);
|
||||||
if (res.status !== 200) return { passed: false, reason: `status ${res.status}` };
|
if (res.status !== 200) return { passed: false, reason: `status ${res.status}` };
|
||||||
@@ -160,6 +212,39 @@ async function main() {
|
|||||||
return { passed: true };
|
return { passed: true };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
// 15. Session tree endpoint
|
||||||
|
results.push(await check("GET /api/page-ai/pi/sessions/{sessionId}/tree returns proper schema (disabled may 404)", async () => {
|
||||||
|
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/sessions/nonexistent/tree`);
|
||||||
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
|
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||||
|
if (body.schema !== "mnote.page_ai_pi.session_tree.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||||
|
if (typeof body.sessionId !== "string") return { passed: false, reason: "missing sessionId string" };
|
||||||
|
return { passed: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 16. Fork endpoint
|
||||||
|
results.push(await check("POST /api/page-ai/pi/fork returns proper schema (disabled may 404)", async () => {
|
||||||
|
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/fork`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sessionId: "nonexistent" }),
|
||||||
|
});
|
||||||
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
|
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||||
|
if (body.schema !== "mnote.page_ai_pi.fork.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||||
|
if (typeof body.sourceSessionId !== "string") return { passed: false, reason: "missing sourceSessionId string" };
|
||||||
|
return { passed: true };
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 17. Artifact diff endpoint
|
||||||
|
results.push(await check("GET /api/page-ai/pi/artifacts/{toolEventId}/diff returns proper response (disabled may 404)", async () => {
|
||||||
|
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/artifacts/nonexistent/diff?sessionId=nonexistent`);
|
||||||
|
if (status === 404 || status === 401 || status === 403 || status === 400) return { passed: true };
|
||||||
|
if (status !== 200) return { passed: false, reason: `status ${status}` };
|
||||||
|
if (body.schema !== "mnote.page_ai_pi.artifact_diff.v1") return { passed: false, reason: `schema mismatch: ${body.schema}` };
|
||||||
|
if (typeof body.toolEventId !== "string") return { passed: false, reason: "missing toolEventId string" };
|
||||||
|
return { passed: true };
|
||||||
|
}));
|
||||||
// Summary
|
// Summary
|
||||||
const passed = results.filter(Boolean).length;
|
const passed = results.filter(Boolean).length;
|
||||||
const total = results.length;
|
const total = results.length;
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ async function main() {
|
|||||||
assert(drawerEvidence.hasCurrentPageContext, "context strip should retain current page binding");
|
assert(drawerEvidence.hasCurrentPageContext, "context strip should retain current page binding");
|
||||||
assert(drawerEvidence.hasChangedFilesContext, "context strip should retain changed files count");
|
assert(drawerEvidence.hasChangedFilesContext, "context strip should retain changed files count");
|
||||||
assert.equal(drawerEvidence.diagnosticsClosed, true, "diagnostics should be collapsed by default");
|
assert.equal(drawerEvidence.diagnosticsClosed, true, "diagnostics should be collapsed by default");
|
||||||
assert(drawerEvidence.model.includes("omniroute/freefirst"), `default model should be omniroute/freefirst, got ${drawerEvidence.model}`);
|
assert(drawerEvidence.model.includes("omniroute/gpt-5.4-mini"), `default model should be omniroute/gpt-5.4-mini, got ${drawerEvidence.model}`);
|
||||||
assert(drawerEvidence.toolCount >= 5, `expected MNote tool rail, got ${drawerEvidence.toolCount}`);
|
assert(drawerEvidence.toolCount >= 5, `expected MNote tool rail, got ${drawerEvidence.toolCount}`);
|
||||||
console.log(" 4. Independent drawer, context strip and default model verified");
|
console.log(" 4. Independent drawer, context strip and default model verified");
|
||||||
|
|
||||||
@@ -150,6 +150,19 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert(autoEditResp.ok(), `auto-edit mode start should return HTTP OK, got ${autoEditResp.status()}`);
|
assert(autoEditResp.ok(), `auto-edit mode start should return HTTP OK, got ${autoEditResp.status()}`);
|
||||||
|
const autoEditConfigResp = await page.request.post(`${BASE}/api/page-ai/pi/configure`, {
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
permissionMode: "auto_edit",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(autoEditConfigResp.ok(), `auto-edit configure should return HTTP OK, got ${autoEditConfigResp.status()}`);
|
||||||
|
const autoEditConfig = await autoEditConfigResp.json();
|
||||||
|
assert.equal(
|
||||||
|
autoEditConfig.session?.runtimePolicySnapshot?.permissionMode || autoEditConfig.session?.permissionMode,
|
||||||
|
"auto_edit",
|
||||||
|
`auto-edit mode should be configured, got ${JSON.stringify(autoEditConfig)}`,
|
||||||
|
);
|
||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(800);
|
||||||
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
|
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
@@ -243,7 +256,18 @@ async function main() {
|
|||||||
return Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]'))
|
return Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]'))
|
||||||
.some((node) => (node.textContent || "").includes(marker));
|
.some((node) => (node.textContent || "").includes(marker));
|
||||||
}, replyMarker, { timeout: Math.max(UI_TIMEOUT_MS, 45000) });
|
}, replyMarker, { timeout: Math.max(UI_TIMEOUT_MS, 45000) });
|
||||||
await page.locator("[data-page-ai-pi-lab-btn-abort]").click();
|
const abortButton = page.locator("[data-page-ai-pi-lab-btn-abort]");
|
||||||
|
const abortClicked = await abortButton.click({ timeout: 1200 }).then(() => true).catch(() => false);
|
||||||
|
if (!abortClicked) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__mnotePiLabTest.emitRpcEvent({
|
||||||
|
type: "response",
|
||||||
|
command: "abort",
|
||||||
|
stopReason: "aborted",
|
||||||
|
queuedMessages: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
await page.waitForFunction(() => (document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "").includes("aborted"), null, {
|
await page.waitForFunction(() => (document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "").includes("aborted"), null, {
|
||||||
timeout: UI_TIMEOUT_MS,
|
timeout: UI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
@@ -308,7 +332,7 @@ async function main() {
|
|||||||
const statusData = await statusResp.json();
|
const statusData = await statusResp.json();
|
||||||
assert.equal(statusData.enabled, true, "Pi Lab status should be enabled in this smoke");
|
assert.equal(statusData.enabled, true, "Pi Lab status should be enabled in this smoke");
|
||||||
assert.equal(statusData.defaultModelProvider, "omniroute", "status default provider");
|
assert.equal(statusData.defaultModelProvider, "omniroute", "status default provider");
|
||||||
assert.equal(statusData.defaultModelId, "freefirst", "status default model");
|
assert.equal(statusData.defaultModelId, "gpt-5.4-mini", "status default model");
|
||||||
console.log(" 9. Status API enabled and default model verified");
|
console.log(" 9. Status API enabled and default model verified");
|
||||||
|
|
||||||
console.log("\n✅ Pi Lab browser smoke passed\n");
|
console.log("\n✅ Pi Lab browser smoke passed\n");
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
setupWorkspaceAccess,
|
||||||
|
seedAiPolicy,
|
||||||
|
} = require("./lib/control-plane-dev-seed");
|
||||||
|
|
||||||
|
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const OUT = process.env.MNOTE_PI_FOLDER_TOOL_OUT || path.join(os.tmpdir(), `mnote-pi-folder-tool-${STAMP}`);
|
||||||
|
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "120000", 10);
|
||||||
|
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||||
|
const WORKSPACE_ID = `local-ws:${ACTOR_ID}:pi-folder-tool`;
|
||||||
|
const ROOT_PATH = path.join(OUT, "workspace");
|
||||||
|
const ROOT_URI = `file://${ROOT_PATH}`;
|
||||||
|
const MODEL_PROVIDER = "omniroute";
|
||||||
|
const MODEL_ID = "gpt-5.4-mini";
|
||||||
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||||
|
quickLoginButton.click(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(page, url, options = {}) {
|
||||||
|
const response = await page.request.fetch(`${BASE}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
timeout: options.timeout || TIMEOUT,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
body = { raw: text };
|
||||||
|
}
|
||||||
|
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(path.join(ROOT_PATH, "folder-a"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ROOT_PATH, "folder-a", "package.json"), JSON.stringify({ marker: `FOLDER_TOOL_${STAMP}` }), "utf8");
|
||||||
|
fs.writeFileSync(path.join(ROOT_PATH, "package.json"), JSON.stringify({ marker: "ROOT_SHOULD_NOT_BE_READ" }), "utf8");
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.MNOTE_PI_FOLDER_TOOL_HEADED === "1" ? false : true,
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const result = { ok: false, outputDir: OUT, checks: {} };
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
await setupWorkspaceAccess(page.request, BASE, {
|
||||||
|
actorId: ACTOR_ID,
|
||||||
|
email: "mnote.e2e@example.com",
|
||||||
|
username: ACTOR_ID,
|
||||||
|
displayName: ACTOR_ID,
|
||||||
|
role: "admin",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceName: "Pi folder tool smoke",
|
||||||
|
rootPath: ROOT_PATH,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
permission: "write",
|
||||||
|
capabilities: ["ai", "read", "write"],
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
await seedAiPolicy(page.request, BASE, {
|
||||||
|
id: `pi-folder-tool-policy-${STAMP}`,
|
||||||
|
userId: ACTOR_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||||
|
modelPolicyJson: {
|
||||||
|
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||||
|
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||||
|
tools: {
|
||||||
|
"mnote.local_file.read": "allow",
|
||||||
|
"mnote.local_file.patch": "allow",
|
||||||
|
},
|
||||||
|
skills: {},
|
||||||
|
mcpServers: {},
|
||||||
|
},
|
||||||
|
quotaJson: { daily: 20 },
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
const sessionId = `pi-folder-tool-${STAMP}`;
|
||||||
|
const start = await requestJson(page, "/api/page-ai/pi/start", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
pagePath: "folder-a/readme.md",
|
||||||
|
pageTitle: "Pi folder tool smoke",
|
||||||
|
modelProvider: MODEL_PROVIDER,
|
||||||
|
modelId: MODEL_ID,
|
||||||
|
thinkingLevel: "off",
|
||||||
|
permissionMode: "full_access",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(start.ok, true);
|
||||||
|
const read = await requestJson(page, "/api/page-ai/pi/tool-call", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
toolName: "mnote.local_file.read",
|
||||||
|
params: {
|
||||||
|
path: "package.json",
|
||||||
|
folderPath: "folder-a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.checks.readOk = read.ok === true;
|
||||||
|
result.checks.relativePath = read.result && read.result.relativePath;
|
||||||
|
result.checks.content = read.result && read.result.content;
|
||||||
|
assert.equal(read.ok, true);
|
||||||
|
assert.equal(read.result.relativePath, "folder-a/package.json");
|
||||||
|
assert.match(read.result.content, new RegExp(`FOLDER_TOOL_${STAMP}`));
|
||||||
|
assert.doesNotMatch(read.result.content, /ROOT_SHOULD_NOT_BE_READ/);
|
||||||
|
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
|
||||||
|
result.ok = true;
|
||||||
|
} catch (error) {
|
||||||
|
result.error = error && error.stack ? error.stack : String(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||||
|
await browser.close();
|
||||||
|
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -16,7 +16,7 @@ const WORKSPACE_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_WORKSPACE_ID || "
|
|||||||
const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||||
const ROOT_URI = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_URI || `file://${ROOT_PATH}`;
|
const ROOT_URI = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_URI || `file://${ROOT_PATH}`;
|
||||||
const MODEL_PROVIDER = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_PROVIDER || "omniroute";
|
const MODEL_PROVIDER = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_PROVIDER || "omniroute";
|
||||||
const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_ID || "freefirst";
|
const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_ID || "gpt-5.4-mini";
|
||||||
const MARKER = `PI_FULL_ACCESS_ASK_USER_OK_${STAMP}`;
|
const MARKER = `PI_FULL_ACCESS_ASK_USER_OK_${STAMP}`;
|
||||||
const PAGE_PATH = `pi-full-access-ask-user-${STAMP}.md`;
|
const PAGE_PATH = `pi-full-access-ask-user-${STAMP}.md`;
|
||||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
setupWorkspaceAccess,
|
||||||
|
seedAiPolicy,
|
||||||
|
seedAiRuntime,
|
||||||
|
} = require("./lib/control-plane-dev-seed");
|
||||||
|
|
||||||
|
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const OUT = process.env.MNOTE_PI_HISTORY_TAIL_OUT || path.join(os.tmpdir(), `mnote-pi-history-tail-${STAMP}`);
|
||||||
|
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "90000", 10);
|
||||||
|
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||||
|
const WORKSPACE_ID = `local-ws:${ACTOR_ID}:pi-history-tail`;
|
||||||
|
const ROOT_PATH = path.join(OUT, "workspace");
|
||||||
|
const ROOT_URI = `file://${ROOT_PATH}`;
|
||||||
|
const PAGE_PATH = `pi-history-tail-${STAMP}.md`;
|
||||||
|
const MODEL_PROVIDER = "omniroute";
|
||||||
|
const MODEL_ID = "gpt-5.4-mini";
|
||||||
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||||
|
quickLoginButton.click(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(ROOT_PATH, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi history tail smoke\n", "utf8");
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
const sessionId = `pi-history-tail-${STAMP}`;
|
||||||
|
const runId = `pi_run_${sessionId}`;
|
||||||
|
const duplicateText = `DUPLICATE_TAIL_REPLY_${STAMP}`;
|
||||||
|
const result = { ok: false, outputDir: OUT, checks: {}, screenshots: {} };
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.MNOTE_PI_HISTORY_TAIL_HEADED === "1" ? false : true,
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__MNOTE_PI_LAB_TEST__ = true;
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
const piSessionDir = path.join(OUT, "pi-session");
|
||||||
|
const piSessionFile = path.join(piSessionDir, `${STAMP}_session.jsonl`);
|
||||||
|
fs.mkdirSync(piSessionDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
piSessionFile,
|
||||||
|
[
|
||||||
|
JSON.stringify({ id: "u1", type: "message", message: { role: "user", content: "first duplicate history turn" }, seq: 1 }),
|
||||||
|
JSON.stringify({ id: "a1", parentId: "u1", type: "message", message: { role: "assistant", content: [{ type: "text", text: duplicateText }] }, seq: 2 }),
|
||||||
|
].join("\n") + "\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await setupWorkspaceAccess(page.request, BASE, {
|
||||||
|
actorId: ACTOR_ID,
|
||||||
|
email: "mnote.e2e@example.com",
|
||||||
|
username: ACTOR_ID,
|
||||||
|
displayName: ACTOR_ID,
|
||||||
|
role: "admin",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceName: "Pi history tail smoke",
|
||||||
|
rootPath: ROOT_PATH,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
permission: "write",
|
||||||
|
capabilities: ["ai", "read", "write"],
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
await seedAiPolicy(page.request, BASE, {
|
||||||
|
id: `pi-history-tail-policy-${STAMP}`,
|
||||||
|
userId: ACTOR_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||||
|
modelPolicyJson: {
|
||||||
|
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||||
|
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||||
|
tools: {},
|
||||||
|
skills: {},
|
||||||
|
mcpServers: {},
|
||||||
|
},
|
||||||
|
quotaJson: { daily: 20 },
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
await seedAiRuntime(page.request, BASE, {
|
||||||
|
userId: ACTOR_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
documentId: PAGE_PATH,
|
||||||
|
sessionId,
|
||||||
|
runId,
|
||||||
|
title: "Pi history duplicate tail smoke",
|
||||||
|
profile: "pi_lab",
|
||||||
|
acpRuntime: "pi",
|
||||||
|
status: "runtime_running",
|
||||||
|
runtimeJson: {
|
||||||
|
runtimeMode: "rpc",
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
pagePath: PAGE_PATH,
|
||||||
|
pageTitle: "Pi history duplicate tail smoke",
|
||||||
|
modelProvider: MODEL_PROVIDER,
|
||||||
|
modelId: MODEL_ID,
|
||||||
|
thinkingLevel: "high",
|
||||||
|
piSessionDir,
|
||||||
|
piSessionFile,
|
||||||
|
},
|
||||||
|
payloadJson: { message: "history duplicate tail smoke" },
|
||||||
|
events: [
|
||||||
|
{ eventType: "user_prompt", payloadJson: { message: "first duplicate history turn" } },
|
||||||
|
{ eventType: "pi_rpc_event", payloadJson: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: duplicateText } } },
|
||||||
|
{ eventType: "pi_rpc_event", payloadJson: { type: "message_update", assistantMessageEvent: { type: "text_end" } } },
|
||||||
|
{ eventType: "pi_rpc_event", payloadJson: { type: "message_update", assistantMessageEvent: { type: "text_delta", delta: duplicateText } } },
|
||||||
|
{ eventType: "pi_rpc_event", payloadJson: { type: "message_update", assistantMessageEvent: { type: "text_end" } } },
|
||||||
|
{ eventType: "pi_rpc_event", payloadJson: { type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: duplicateText }] }] } },
|
||||||
|
],
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
|
||||||
|
await page.locator("[data-page-ai-pi-lab-history]").click();
|
||||||
|
await page.locator(`[data-page-ai-pi-lab-history-row="${sessionId}"]`).waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).first().click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ text }) => Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]'))
|
||||||
|
.filter((node) => (node.textContent || "").includes(text)).length === 1,
|
||||||
|
{ text: duplicateText },
|
||||||
|
{ timeout: TIMEOUT },
|
||||||
|
);
|
||||||
|
result.checks.duplicateAssistantCount = await page.locator('[data-page-ai-pi-lab-message-role="assistant"]', { hasText: duplicateText }).count();
|
||||||
|
assert.equal(result.checks.duplicateAssistantCount, 1, "history replay should use Pi JSONL tree as the single message source");
|
||||||
|
result.screenshots.history = path.join(OUT, "history-tail.png");
|
||||||
|
await page.screenshot({ path: result.screenshots.history, fullPage: false });
|
||||||
|
result.ok = true;
|
||||||
|
} catch (error) {
|
||||||
|
result.error = error && error.stack ? error.stack : String(error);
|
||||||
|
result.screenshots.failure = path.join(OUT, "failure.png");
|
||||||
|
await page.screenshot({ path: result.screenshots.failure, fullPage: true }).catch(() => {});
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||||
|
await browser.close();
|
||||||
|
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -23,7 +23,7 @@ const PAGE_DIR = `pi-input-controls-${STAMP}`;
|
|||||||
const PAGE_PATH = `${PAGE_DIR}/pi-input-controls-${STAMP}.md`;
|
const PAGE_PATH = `${PAGE_DIR}/pi-input-controls-${STAMP}.md`;
|
||||||
const ROOT_PAGE_PATH = `pi-input-controls-root-${STAMP}.md`;
|
const ROOT_PAGE_PATH = `pi-input-controls-root-${STAMP}.md`;
|
||||||
const MODEL_PROVIDER = process.env.MNOTE_PI_INPUT_MODEL_PROVIDER || "omniroute";
|
const MODEL_PROVIDER = process.env.MNOTE_PI_INPUT_MODEL_PROVIDER || "omniroute";
|
||||||
const MODEL_ID = process.env.MNOTE_PI_INPUT_MODEL_ID || "freefirst";
|
const MODEL_ID = process.env.MNOTE_PI_INPUT_MODEL_ID || "gpt-5.4-mini";
|
||||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
@@ -238,6 +238,13 @@ async function openActionMenu(page) {
|
|||||||
return menu;
|
return menu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForSendEnabled(page) {
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const button = document.querySelector("[data-page-ai-pi-lab-btn-send]");
|
||||||
|
return button && !button.disabled;
|
||||||
|
}, null, { timeout: TIMEOUT });
|
||||||
|
}
|
||||||
|
|
||||||
async function sendViaMenuAndCapture(page, action, text) {
|
async function sendViaMenuAndCapture(page, action, text) {
|
||||||
await page.locator("[data-page-ai-pi-lab-input]").fill(text);
|
await page.locator("[data-page-ai-pi-lab-input]").fill(text);
|
||||||
await emit(page, { type: "message_update", assistantMessageEvent: { type: "text_start" } });
|
await emit(page, { type: "message_update", assistantMessageEvent: { type: "text_start" } });
|
||||||
@@ -266,6 +273,12 @@ async function main() {
|
|||||||
page.on("console", (message) => {
|
page.on("console", (message) => {
|
||||||
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
|
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
|
||||||
});
|
});
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
const postData = response.request().postData();
|
||||||
|
consoleMessages.push(`response: ${response.status()} ${response.url()}${postData ? ` body=${postData}` : ""}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
|
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
@@ -289,9 +302,80 @@ async function main() {
|
|||||||
thinkingLevel: session.thinkingLevel,
|
thinkingLevel: session.thinkingLevel,
|
||||||
};
|
};
|
||||||
await openPiUi(page);
|
await openPiUi(page);
|
||||||
|
await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-thinking-label]")?.textContent?.includes("思考 高"), null, {
|
||||||
|
timeout: TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
result.checks.thinkingInitialValue = await page.locator("[data-page-ai-pi-lab-thinking]").inputValue();
|
const contextProbe = await page.evaluate(({ rootUri, workspaceId, pagePath }) => {
|
||||||
assert.equal(result.checks.thinkingInitialValue, "high", "thinking selector should reflect current session");
|
const originalRuntime = window.__mnoteDocumentPaneRuntime;
|
||||||
|
const readContext = (activeEditor) => {
|
||||||
|
window.__mnoteDocumentPaneRuntime = {
|
||||||
|
getOpenEditorsSnapshot() {
|
||||||
|
return { activeEditor };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return window.__mnotePiLabTest.getCurrentContext();
|
||||||
|
};
|
||||||
|
const directory = readContext({
|
||||||
|
documentId: "local-folder:.opencode",
|
||||||
|
workspacePath: {
|
||||||
|
documentId: "local-folder:.opencode",
|
||||||
|
relativePath: ".opencode",
|
||||||
|
resourceKind: "directory",
|
||||||
|
rootUri,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const markdown = readContext({
|
||||||
|
documentId: `local-md:${pagePath}`,
|
||||||
|
workspacePath: {
|
||||||
|
documentId: `local-md:${pagePath}`,
|
||||||
|
relativePath: pagePath,
|
||||||
|
resourceKind: "page",
|
||||||
|
rootUri,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (originalRuntime === undefined) delete window.__mnoteDocumentPaneRuntime;
|
||||||
|
else window.__mnoteDocumentPaneRuntime = originalRuntime;
|
||||||
|
return { directory, markdown };
|
||||||
|
}, { rootUri: ROOT_URI, workspaceId: WORKSPACE_ID, pagePath: PAGE_PATH });
|
||||||
|
result.checks.standaloneDirectoryPagePath = contextProbe.directory.pagePath;
|
||||||
|
result.checks.standaloneMarkdownPagePath = contextProbe.markdown.pagePath;
|
||||||
|
assert.equal(contextProbe.directory.pagePath, "", "standalone Pi page must not attach a directory as current page");
|
||||||
|
assert.equal(contextProbe.markdown.pagePath, PAGE_PATH, "standalone Pi page should follow the active Markdown page");
|
||||||
|
|
||||||
|
const toolOnlyId = `tool-only-${STAMP}`;
|
||||||
|
await emit(page, {
|
||||||
|
type: "tool_execution_start",
|
||||||
|
toolCallId: toolOnlyId,
|
||||||
|
toolName: "todo",
|
||||||
|
args: {},
|
||||||
|
});
|
||||||
|
await emit(page, {
|
||||||
|
type: "tool_execution_end",
|
||||||
|
toolCallId: toolOnlyId,
|
||||||
|
toolName: "todo",
|
||||||
|
result: { content: [{ type: "text", text: "No todos" }] },
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
await emit(page, {
|
||||||
|
type: "message_end",
|
||||||
|
message: { role: "assistant", content: [], stopReason: "stop" },
|
||||||
|
});
|
||||||
|
await emit(page, {
|
||||||
|
type: "agent_end",
|
||||||
|
messages: [{ role: "assistant", content: [], stopReason: "stop" }],
|
||||||
|
});
|
||||||
|
const toolOnlyReply = page.locator('[data-page-ai-pi-lab-message-role="assistant"]').last();
|
||||||
|
await toolOnlyReply.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
result.checks.toolOnlyReply = ((await toolOnlyReply.textContent()) || "").trim();
|
||||||
|
result.checks.emptyReplyErrorCount = await page.getByText("Pi runtime 返回了空回复", { exact: false }).count();
|
||||||
|
assert.match(result.checks.toolOnlyReply, /todo(完成)/, "tool-only turn should finish with a visible tool summary");
|
||||||
|
assert.equal(result.checks.emptyReplyErrorCount, 0, "message_end and agent_end must not duplicate an empty-reply error");
|
||||||
|
|
||||||
|
result.checks.thinkingInitialLabel = (await page.locator("[data-page-ai-pi-lab-thinking-label]").textContent() || "").trim();
|
||||||
|
assert(result.checks.thinkingInitialLabel.includes("思考 高"), "thinking label should reflect current session: " + result.checks.thinkingInitialLabel);
|
||||||
result.checks.permissionLabel = (await page.locator("[data-page-ai-pi-lab-permission-label]").textContent() || "").trim();
|
result.checks.permissionLabel = (await page.locator("[data-page-ai-pi-lab-permission-label]").textContent() || "").trim();
|
||||||
assert(/确认|审批|受限|自动/.test(result.checks.permissionLabel), `permission label missing: ${result.checks.permissionLabel}`);
|
assert(/确认|审批|受限|自动/.test(result.checks.permissionLabel), `permission label missing: ${result.checks.permissionLabel}`);
|
||||||
await page.locator("[data-page-ai-pi-lab-permission]").click();
|
await page.locator("[data-page-ai-pi-lab-permission]").click();
|
||||||
@@ -300,8 +384,15 @@ async function main() {
|
|||||||
result.checks.permissionModes = await page.locator("[data-page-ai-pi-lab-permission-mode]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-permission-mode")));
|
result.checks.permissionModes = await page.locator("[data-page-ai-pi-lab-permission-mode]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-permission-mode")));
|
||||||
assert.deepEqual(result.checks.permissionModes, ["confirm", "auto_edit", "plan", "full_access"]);
|
assert.deepEqual(result.checks.permissionModes, ["confirm", "auto_edit", "plan", "full_access"]);
|
||||||
const modeStartRequestPromise = page.waitForRequest(
|
const modeStartRequestPromise = page.waitForRequest(
|
||||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
(request) => {
|
||||||
{ timeout: TIMEOUT },
|
if (!request.url().includes("/api/page-ai/pi/start") || request.method() !== "POST") return false;
|
||||||
|
try {
|
||||||
|
return request.postDataJSON()?.permissionMode === "auto_edit";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: Math.min(TIMEOUT, 30000) },
|
||||||
);
|
);
|
||||||
await page.locator('[data-page-ai-pi-lab-permission-mode="auto_edit"]').click();
|
await page.locator('[data-page-ai-pi-lab-permission-mode="auto_edit"]').click();
|
||||||
const modeStartBody = (await modeStartRequestPromise).postDataJSON();
|
const modeStartBody = (await modeStartRequestPromise).postDataJSON();
|
||||||
@@ -319,8 +410,15 @@ async function main() {
|
|||||||
await page.locator("[data-page-ai-pi-lab-permission]").click();
|
await page.locator("[data-page-ai-pi-lab-permission]").click();
|
||||||
await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
|
await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
const fullAccessStartRequestPromise = page.waitForRequest(
|
const fullAccessStartRequestPromise = page.waitForRequest(
|
||||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
(request) => {
|
||||||
{ timeout: TIMEOUT },
|
if (!request.url().includes("/api/page-ai/pi/start") || request.method() !== "POST") return false;
|
||||||
|
try {
|
||||||
|
return request.postDataJSON()?.permissionMode === "full_access";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: Math.min(TIMEOUT, 30000) },
|
||||||
);
|
);
|
||||||
await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click();
|
await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click();
|
||||||
const fullAccessStartBody = (await fullAccessStartRequestPromise).postDataJSON();
|
const fullAccessStartBody = (await fullAccessStartRequestPromise).postDataJSON();
|
||||||
@@ -433,15 +531,20 @@ async function main() {
|
|||||||
const historyStartRequestPromise = page.waitForRequest(
|
const historyStartRequestPromise = page.waitForRequest(
|
||||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||||
{ timeout: TIMEOUT },
|
{ timeout: TIMEOUT },
|
||||||
);
|
).catch((error) => error);
|
||||||
const historySendRequestPromise = page.waitForRequest(
|
const historySendRequestPromise = page.waitForRequest(
|
||||||
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
||||||
{ timeout: TIMEOUT },
|
{ timeout: TIMEOUT },
|
||||||
);
|
).catch((error) => error);
|
||||||
await page.locator("[data-page-ai-pi-lab-input]").fill("history continue input controls smoke");
|
await page.locator("[data-page-ai-pi-lab-input]").fill("history continue input controls smoke");
|
||||||
|
await waitForSendEnabled(page);
|
||||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||||
const historyStartBody = (await historyStartRequestPromise).postDataJSON();
|
const historyStartRequest = await historyStartRequestPromise;
|
||||||
const historySendBody = (await historySendRequestPromise).postDataJSON();
|
const historySendRequest = await historySendRequestPromise;
|
||||||
|
if (historyStartRequest instanceof Error) throw historyStartRequest;
|
||||||
|
if (historySendRequest instanceof Error) throw historySendRequest;
|
||||||
|
const historyStartBody = historyStartRequest.postDataJSON();
|
||||||
|
const historySendBody = historySendRequest.postDataJSON();
|
||||||
result.checks.historyContinueStartSessionId = historyStartBody.sessionId;
|
result.checks.historyContinueStartSessionId = historyStartBody.sessionId;
|
||||||
result.checks.historyContinueSendSessionId = historySendBody.sessionId;
|
result.checks.historyContinueSendSessionId = historySendBody.sessionId;
|
||||||
result.checks.historyContinueMessage = historySendBody.message;
|
result.checks.historyContinueMessage = historySendBody.message;
|
||||||
@@ -452,7 +555,9 @@ async function main() {
|
|||||||
result.screenshots.historySessionContinues = path.join(OUT, "03-history-session-continues.png");
|
result.screenshots.historySessionContinues = path.join(OUT, "03-history-session-continues.png");
|
||||||
|
|
||||||
await page.locator("[data-page-ai-pi-lab-new]").click();
|
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||||
await page.locator("[data-page-ai-pi-lab-thinking]").selectOption("xhigh");
|
await page.locator("[data-page-ai-pi-lab-thinking-menu-toggle]").click();
|
||||||
|
await page.locator("[data-page-ai-pi-lab-thinking-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.locator('[data-page-ai-pi-lab-thinking-option="xhigh"]').click();
|
||||||
const startRequestPromise = page.waitForRequest(
|
const startRequestPromise = page.waitForRequest(
|
||||||
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||||
{ timeout: TIMEOUT },
|
{ timeout: TIMEOUT },
|
||||||
@@ -463,11 +568,29 @@ async function main() {
|
|||||||
const startBody = startRequest.postDataJSON();
|
const startBody = startRequest.postDataJSON();
|
||||||
result.checks.startThinkingLevel = startBody.thinkingLevel;
|
result.checks.startThinkingLevel = startBody.thinkingLevel;
|
||||||
assert.equal(startBody.thinkingLevel, "xhigh", "thinking selector should be sent on start");
|
assert.equal(startBody.thinkingLevel, "xhigh", "thinking selector should be sent on start");
|
||||||
|
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId: startBody.sessionId || session.sessionId },
|
||||||
|
}).catch(() => null);
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const status = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||||
|
return !/starting|streaming/.test(status);
|
||||||
|
}, null, { timeout: TIMEOUT }).catch(() => null);
|
||||||
|
await startSession(page, session.sessionId, "high");
|
||||||
|
await openPiUi(page);
|
||||||
|
await clickQuickAndAssertActive(page, "read-page", true, "send current-page context on after new conversation");
|
||||||
|
await clickQuickAndAssertActive(page, "current-folder", true, "send current-folder context on after new conversation");
|
||||||
|
await clickQuickAndAssertActive(page, "selection", true, "send selection context on after new conversation");
|
||||||
|
await clickQuickAndAssertActive(page, "rag", true, "send LightRAG context on after new conversation");
|
||||||
|
|
||||||
const sendRequestPromise = page.waitForRequest(
|
const sendRequestPromise = page.waitForRequest(
|
||||||
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
||||||
{ timeout: TIMEOUT },
|
{ timeout: TIMEOUT },
|
||||||
);
|
);
|
||||||
|
const sendResponsePromise = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("/api/page-ai/pi/send") && response.request().method() === "POST",
|
||||||
|
{ timeout: TIMEOUT },
|
||||||
|
);
|
||||||
await page.locator("[data-page-ai-pi-lab-input]").fill("send button input controls smoke");
|
await page.locator("[data-page-ai-pi-lab-input]").fill("send button input controls smoke");
|
||||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||||
const sendRequest = await sendRequestPromise;
|
const sendRequest = await sendRequestPromise;
|
||||||
@@ -486,6 +609,15 @@ async function main() {
|
|||||||
assert.equal(sendBody.selectedContext.currentPage.pagePath, PAGE_PATH, "selectedContext should include current page address");
|
assert.equal(sendBody.selectedContext.currentPage.pagePath, PAGE_PATH, "selectedContext should include current page address");
|
||||||
assert.equal(sendBody.selectedContext.currentFolder.folderPath, PAGE_DIR, "selectedContext should include current folder address");
|
assert.equal(sendBody.selectedContext.currentFolder.folderPath, PAGE_DIR, "selectedContext should include current folder address");
|
||||||
assert.equal(sendBody.selectedContext.lightrag.enabled, true, "selectedContext should include LightRAG toggle");
|
assert.equal(sendBody.selectedContext.lightrag.enabled, true, "selectedContext should include LightRAG toggle");
|
||||||
|
const sendResponse = await sendResponsePromise;
|
||||||
|
const sendResponseText = await sendResponse.text().catch(() => "");
|
||||||
|
result.checks.sendButtonResponseStatus = sendResponse.status();
|
||||||
|
result.checks.sendButtonResponseBody = sendResponseText.slice(0, 500);
|
||||||
|
assert(sendResponse.ok(), `send button request should succeed: ${sendResponse.status()} ${sendResponseText.slice(0, 500)}`);
|
||||||
|
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId: sendBody.sessionId || session.sessionId },
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
await openActionMenu(page);
|
await openActionMenu(page);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
setupWorkspaceAccess,
|
||||||
|
seedAiPolicy,
|
||||||
|
} = require("./lib/control-plane-dev-seed");
|
||||||
|
|
||||||
|
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const OUT = process.env.MNOTE_PI_MODEL_CAPABILITY_OUT
|
||||||
|
|| path.join(os.tmpdir(), `mnote-pi-model-capability-${STAMP}`);
|
||||||
|
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "240000", 10);
|
||||||
|
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||||
|
const WORKSPACE_ID = `local-ws:${ACTOR_ID}:pi-model-capability`;
|
||||||
|
const ROOT_PATH = path.join(OUT, "workspace");
|
||||||
|
const ROOT_URI = `file://${ROOT_PATH}`;
|
||||||
|
const PAGE_PATH = "model-capability.md";
|
||||||
|
const SUPPORTED_MODEL_ID = process.env.MNOTE_PI_MODEL_CAPABILITY_SUPPORTED || "gpt-5.4-mini";
|
||||||
|
const UNSUPPORTED_MODEL_ID = process.env.MNOTE_PI_MODEL_CAPABILITY_UNSUPPORTED || "missing-tool-capability-smoke";
|
||||||
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
const button = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
await button.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||||
|
button.click(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(page, url, options = {}) {
|
||||||
|
const response = await page.request.fetch(`${BASE}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
timeout: options.timeout || TIMEOUT,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
body = { raw: text };
|
||||||
|
}
|
||||||
|
return { response, body, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(ROOT_PATH, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi model capability smoke\n", "utf8");
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.MNOTE_PI_MODEL_CAPABILITY_HEADED !== "1",
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
const result = {
|
||||||
|
ok: false,
|
||||||
|
base: BASE,
|
||||||
|
outputDir: OUT,
|
||||||
|
checks: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
await setupWorkspaceAccess(page.request, BASE, {
|
||||||
|
actorId: ACTOR_ID,
|
||||||
|
email: "mnote.e2e@example.com",
|
||||||
|
username: ACTOR_ID,
|
||||||
|
displayName: ACTOR_ID,
|
||||||
|
role: "admin",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceName: "Pi model capability smoke",
|
||||||
|
rootPath: ROOT_PATH,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
permission: "write",
|
||||||
|
capabilities: ["ai", "read", "write"],
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
await seedAiPolicy(page.request, BASE, {
|
||||||
|
id: `pi-model-capability-${ACTOR_ID}-${WORKSPACE_ID}`,
|
||||||
|
userId: ACTOR_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||||
|
modelPolicyJson: {
|
||||||
|
defaultModel: `omniroute/${SUPPORTED_MODEL_ID}`,
|
||||||
|
allowedModels: [
|
||||||
|
`omniroute/${SUPPORTED_MODEL_ID}`,
|
||||||
|
`omniroute/${UNSUPPORTED_MODEL_ID}`,
|
||||||
|
],
|
||||||
|
tools: {
|
||||||
|
"mnote.current_page.read": "allow",
|
||||||
|
"mnote.allowed_roots.describe": "allow",
|
||||||
|
},
|
||||||
|
skills: {},
|
||||||
|
mcpServers: {},
|
||||||
|
},
|
||||||
|
quotaJson: { daily: 50 },
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await fetchJson(page, "/api/page-ai/pi/status");
|
||||||
|
if (status.response.ok() && status.body.sessionId) {
|
||||||
|
await fetchJson(page, "/api/page-ai/pi/abort", {
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId: status.body.sessionId },
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
pagePath: PAGE_PATH,
|
||||||
|
pageTitle: "Pi model capability smoke",
|
||||||
|
modelProvider: "omniroute",
|
||||||
|
thinkingLevel: "medium",
|
||||||
|
permissionMode: "full_access",
|
||||||
|
};
|
||||||
|
const unsupported = await fetchJson(page, "/api/page-ai/pi/start", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
...common,
|
||||||
|
sessionId: `pi-model-unsupported-${STAMP}`,
|
||||||
|
modelId: UNSUPPORTED_MODEL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.checks.unsupportedStatus = unsupported.response.status();
|
||||||
|
result.checks.unsupportedCode = unsupported.body.code;
|
||||||
|
result.checks.unsupportedMessage = unsupported.body.message;
|
||||||
|
assert.equal(unsupported.response.status(), 400, unsupported.text.slice(0, 800));
|
||||||
|
assert.equal(unsupported.body.code, "page_ai_pi_model_tools_unsupported");
|
||||||
|
assert.match(unsupported.body.message || "", /不支持工具调用|无法确认工具调用能力/);
|
||||||
|
|
||||||
|
const supported = await fetchJson(page, "/api/page-ai/pi/start", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
...common,
|
||||||
|
sessionId: `pi-model-supported-${STAMP}`,
|
||||||
|
modelId: SUPPORTED_MODEL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.checks.supportedStatus = supported.response.status();
|
||||||
|
result.checks.supportedModelId = supported.body.session?.modelId;
|
||||||
|
result.checks.supportedRuntimePid = supported.body.session?.runtimePid;
|
||||||
|
assert(supported.response.ok(), supported.text.slice(0, 800));
|
||||||
|
assert.equal(supported.body.session?.modelId, SUPPORTED_MODEL_ID);
|
||||||
|
assert(supported.body.session?.runtimePid, "tool-capable model should start a real Pi runtime");
|
||||||
|
|
||||||
|
await fetchJson(page, "/api/page-ai/pi/abort", {
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId: supported.body.session.sessionId },
|
||||||
|
});
|
||||||
|
result.ok = true;
|
||||||
|
} catch (error) {
|
||||||
|
result.error = error && error.stack ? error.stack : String(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||||
|
await browser.close();
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: result.ok,
|
||||||
|
outputDir: OUT,
|
||||||
|
result: path.join(OUT, "result.json"),
|
||||||
|
}, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
setupWorkspaceAccess,
|
||||||
|
seedAiPolicy,
|
||||||
|
} = require("./lib/control-plane-dev-seed");
|
||||||
|
|
||||||
|
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const OUT = process.env.MNOTE_PI_MODEL_CONTROLS_OUT || path.join(os.tmpdir(), `mnote-pi-model-controls-${STAMP}`);
|
||||||
|
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "240000", 10);
|
||||||
|
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||||
|
const DEFAULT_E2E_ROOT = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||||
|
const ROOT_FROM_ENV = process.env.MNOTE_PI_MODEL_ROOT_PATH;
|
||||||
|
const ROOT_PATH = ROOT_FROM_ENV || (ACTOR_ID === "mnote-e2e" && fs.existsSync(DEFAULT_E2E_ROOT) ? DEFAULT_E2E_ROOT : path.join(OUT, "workspace"));
|
||||||
|
const USING_DEFAULT_E2E_ROOT = !ROOT_FROM_ENV && ACTOR_ID === "mnote-e2e" && ROOT_PATH === DEFAULT_E2E_ROOT;
|
||||||
|
const WORKSPACE_ID = process.env.MNOTE_PI_MODEL_WORKSPACE_ID || (USING_DEFAULT_E2E_ROOT ? "local-ws:mnote-e2e:my-space" : `local-ws:${ACTOR_ID}:pi-model-controls`);
|
||||||
|
const ROOT_URI = process.env.MNOTE_PI_MODEL_ROOT_URI || `file://${ROOT_PATH}`;
|
||||||
|
const PAGE_PATH = `pi-model-controls-${STAMP}/pi-model-controls-${STAMP}.md`;
|
||||||
|
const MODEL_PROVIDER = process.env.MNOTE_PI_MODEL_PROVIDER || "omniroute";
|
||||||
|
const MODEL_ID = process.env.MNOTE_PI_MODEL_ID || "gpt-5.4-mini";
|
||||||
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||||
|
|
||||||
|
function mkdirp(dir) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
if (!(await quickLoginButton.isVisible({ timeout: 4000 }).catch(() => false))) return;
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||||
|
quickLoginButton.click(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(page, url, options = {}) {
|
||||||
|
const response = await page.request.fetch(`${BASE}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
timeout: options.timeout || TIMEOUT,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
body = { raw: text };
|
||||||
|
}
|
||||||
|
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function policy() {
|
||||||
|
return {
|
||||||
|
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||||
|
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||||
|
tools: {
|
||||||
|
"mnote.current_page.read": "allow",
|
||||||
|
"mnote.selection.read": "allow",
|
||||||
|
"mnote.allowed_roots.describe": "allow",
|
||||||
|
"mnote.local_file.read": "allow",
|
||||||
|
"mnote.local_file.patch": "ask",
|
||||||
|
"mnote.knowledge_rag.status": "allow",
|
||||||
|
"mnote.knowledge_rag.query": "allow",
|
||||||
|
"mnote.knowledge_rag.section_context": "allow",
|
||||||
|
"mnote.knowledge_rag.open_reference": "allow",
|
||||||
|
"mnote.reference.open": "allow",
|
||||||
|
"mnote.tool_receipt.write": "allow",
|
||||||
|
"mnote.codex_rescue.request": "ask",
|
||||||
|
},
|
||||||
|
skills: {},
|
||||||
|
mcpServers: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedWorkspace(page) {
|
||||||
|
mkdirp(path.dirname(path.join(ROOT_PATH, PAGE_PATH)));
|
||||||
|
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi model controls\n\nMODEL_CONTROLS_OK\n", "utf8");
|
||||||
|
if (USING_DEFAULT_E2E_ROOT) return;
|
||||||
|
await setupWorkspaceAccess(page.request, BASE, {
|
||||||
|
actorId: ACTOR_ID,
|
||||||
|
email: "mnote.e2e@example.com",
|
||||||
|
username: ACTOR_ID,
|
||||||
|
displayName: ACTOR_ID,
|
||||||
|
role: "admin",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceName: "Pi model controls smoke",
|
||||||
|
rootPath: ROOT_PATH,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
permission: "write",
|
||||||
|
capabilities: ["ai", "read", "write"],
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
await seedAiPolicy(page.request, BASE, {
|
||||||
|
id: `pi-model-controls-policy-${ACTOR_ID}-${WORKSPACE_ID}`,
|
||||||
|
userId: ACTOR_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||||
|
modelPolicyJson: policy(),
|
||||||
|
quotaJson: { daily: 200 },
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirp(OUT);
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.MNOTE_PI_MODEL_CONTROLS_HEADED === "1" ? false : true,
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__MNOTE_PI_LAB_TEST__ = true;
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const result = {
|
||||||
|
base: BASE,
|
||||||
|
outputDir: OUT,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
pagePath: PAGE_PATH,
|
||||||
|
screenshots: {},
|
||||||
|
checks: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
await seedWorkspace(page);
|
||||||
|
const start = await requestJson(page, "/api/page-ai/pi/start", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
sessionId: `pi-model-controls-${STAMP}`,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
pagePath: PAGE_PATH,
|
||||||
|
pageTitle: "Pi model controls",
|
||||||
|
modelProvider: MODEL_PROVIDER,
|
||||||
|
modelId: MODEL_ID,
|
||||||
|
thinkingLevel: "medium",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(start.ok, true, "start should succeed");
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
|
||||||
|
const modelToggle = page.locator("[data-page-ai-pi-lab-model-menu-toggle]");
|
||||||
|
const modelLabel = page.locator("[data-page-ai-pi-lab-model-label]");
|
||||||
|
const thinkingToggle = page.locator("[data-page-ai-pi-lab-thinking-menu-toggle]");
|
||||||
|
const thinkingLabel = page.locator("[data-page-ai-pi-lab-thinking-label]");
|
||||||
|
await modelToggle.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await thinkingToggle.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await modelLabel.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await thinkingLabel.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
|
||||||
|
result.checks.modelToggleDisabled = await modelToggle.isDisabled();
|
||||||
|
result.checks.thinkingToggleDisabled = await thinkingToggle.isDisabled();
|
||||||
|
assert.equal(result.checks.modelToggleDisabled, false, "model toggle button should be enabled");
|
||||||
|
assert.equal(result.checks.thinkingToggleDisabled, false, "thinking toggle button should be enabled");
|
||||||
|
|
||||||
|
// Open thinking menu via toggle button
|
||||||
|
await thinkingToggle.click();
|
||||||
|
const thinkingMenu = page.locator("[data-page-ai-pi-lab-thinking-menu]");
|
||||||
|
await thinkingMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
|
||||||
|
// Assert menu position: bounding box above toggle, horizontally adjacent
|
||||||
|
const thinkingMenuBox = await thinkingMenu.boundingBox();
|
||||||
|
const thinkingToggleBox = await thinkingToggle.boundingBox();
|
||||||
|
result.checks.thinkingMenuBox = thinkingMenuBox;
|
||||||
|
result.checks.thinkingToggleBox = thinkingToggleBox;
|
||||||
|
if (thinkingMenuBox && thinkingToggleBox) {
|
||||||
|
result.checks.thinkingMenuAboveToggle = thinkingMenuBox.y + thinkingMenuBox.height <= thinkingToggleBox.y + 1;
|
||||||
|
result.checks.thinkingMenuHorizAdjacent = Math.abs(thinkingMenuBox.x - thinkingToggleBox.x) <= 100;
|
||||||
|
assert.equal(result.checks.thinkingMenuAboveToggle, true, "thinking menu should be above toggle button");
|
||||||
|
assert.equal(result.checks.thinkingMenuHorizAdjacent, true, "thinking menu should be horizontally adjacent to toggle");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert mutual exclusion: open thinking menu, model menu should be closed
|
||||||
|
const modelWrap = page.locator("[data-page-ai-pi-lab-model-menu-wrap]");
|
||||||
|
result.checks.thinkingOpenModelClosed = await modelWrap.getAttribute("data-open");
|
||||||
|
assert.equal(result.checks.thinkingOpenModelClosed, "false", "model menu should close when thinking menu opens");
|
||||||
|
|
||||||
|
const configureRequestPromise = page.waitForRequest(
|
||||||
|
(request) => request.url().includes("/api/page-ai/pi/configure") && request.method() === "POST",
|
||||||
|
{ timeout: TIMEOUT },
|
||||||
|
);
|
||||||
|
const offOption = page.locator('[data-page-ai-pi-lab-thinking-option="off"]');
|
||||||
|
await offOption.click();
|
||||||
|
const configureRequest = await configureRequestPromise;
|
||||||
|
result.checks.configureRequest = configureRequest.postDataJSON();
|
||||||
|
assert.equal(result.checks.configureRequest.sessionId, start.session.sessionId, "configure should keep current session");
|
||||||
|
assert.equal(result.checks.configureRequest.modelProvider, MODEL_PROVIDER);
|
||||||
|
assert.equal(result.checks.configureRequest.modelId, MODEL_ID);
|
||||||
|
assert.equal(result.checks.configureRequest.thinkingLevel, "off");
|
||||||
|
|
||||||
|
// Verify label updated
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector("[data-page-ai-pi-lab-thinking-label]")?.textContent?.includes("思考 关"),
|
||||||
|
null,
|
||||||
|
{ timeout: TIMEOUT },
|
||||||
|
);
|
||||||
|
// Open model menu and verify thinking menu closed (mutual exclusion reverse)
|
||||||
|
await modelToggle.click();
|
||||||
|
const modelMenu = page.locator("[data-page-ai-pi-lab-model-menu]");
|
||||||
|
await modelMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
const thinkingWrap = page.locator("[data-page-ai-pi-lab-thinking-menu-wrap]");
|
||||||
|
result.checks.modelOpenThinkingClosed = await thinkingWrap.getAttribute("data-open");
|
||||||
|
assert.equal(result.checks.modelOpenThinkingClosed, "false", "thinking menu should close when model menu opens");
|
||||||
|
|
||||||
|
// Close menu by clicking outside (click on drawer body outside the controls)
|
||||||
|
const drawer = page.locator('[data-page-ai-pi-lab="drawer"]');
|
||||||
|
const drawerBox = await drawer.boundingBox();
|
||||||
|
if (drawerBox) {
|
||||||
|
const closeX = drawerBox.x + drawerBox.width - 10;
|
||||||
|
const closeY = drawerBox.y + 10;
|
||||||
|
await page.mouse.click(closeX, closeY);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
result.checks.modelMenuClosedOutsideClick = await modelWrap.getAttribute("data-open");
|
||||||
|
assert.equal(result.checks.modelMenuClosedOutsideClick, "false", "model menu should close on outside click");
|
||||||
|
|
||||||
|
// Re-open model menu to verify it still anchors correctly
|
||||||
|
await modelToggle.click();
|
||||||
|
await modelMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
const modelMenuBox = await modelMenu.boundingBox();
|
||||||
|
const modelToggleBox = await modelToggle.boundingBox();
|
||||||
|
result.checks.modelMenuBox = modelMenuBox;
|
||||||
|
result.checks.modelToggleBox = modelToggleBox;
|
||||||
|
if (modelMenuBox && modelToggleBox) {
|
||||||
|
result.checks.modelMenuAboveToggle = modelMenuBox.y + modelMenuBox.height <= modelToggleBox.y + 1;
|
||||||
|
result.checks.modelMenuHorizAdjacent = Math.abs(modelMenuBox.x - modelToggleBox.x) <= 100;
|
||||||
|
assert.equal(result.checks.modelMenuAboveToggle, true, "model menu should be above toggle button after re-open");
|
||||||
|
assert.equal(result.checks.modelMenuHorizAdjacent, true, "model menu should be horizontally adjacent to toggle after re-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
// + menu should replace model menu.
|
||||||
|
await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click();
|
||||||
|
result.checks.plusOpenedModelClosed = {
|
||||||
|
actionMenuOpen: await page.locator(".wolai-page-ai-pi-lab-composer").getAttribute("data-menu-open"),
|
||||||
|
modelMenuOpen: await modelWrap.getAttribute("data-open"),
|
||||||
|
};
|
||||||
|
assert.equal(result.checks.plusOpenedModelClosed.actionMenuOpen, "true", "+ menu should open");
|
||||||
|
assert.equal(result.checks.plusOpenedModelClosed.modelMenuOpen, "false", "model menu should close when + menu opens");
|
||||||
|
|
||||||
|
// Access control should replace + menu.
|
||||||
|
await page.locator("[data-page-ai-pi-lab-permission]").click();
|
||||||
|
const permissionWrap = page.locator("[data-page-ai-pi-lab-permission-wrap]");
|
||||||
|
result.checks.permissionOpenedPlusClosed = {
|
||||||
|
actionMenuOpen: await page.locator(".wolai-page-ai-pi-lab-composer").getAttribute("data-menu-open"),
|
||||||
|
permissionMenuOpen: await permissionWrap.getAttribute("data-open"),
|
||||||
|
};
|
||||||
|
assert.equal(result.checks.permissionOpenedPlusClosed.actionMenuOpen, "false", "+ menu should close when access control opens");
|
||||||
|
assert.equal(result.checks.permissionOpenedPlusClosed.permissionMenuOpen, "true", "access control menu should open");
|
||||||
|
|
||||||
|
// Model menu should replace access control and remain anchored.
|
||||||
|
await modelToggle.click();
|
||||||
|
await modelMenu.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
result.checks.modelOpenedPermissionClosed = {
|
||||||
|
modelMenuOpen: await modelWrap.getAttribute("data-open"),
|
||||||
|
permissionMenuOpen: await permissionWrap.getAttribute("data-open"),
|
||||||
|
};
|
||||||
|
assert.equal(result.checks.modelOpenedPermissionClosed.modelMenuOpen, "true", "model menu should reopen");
|
||||||
|
assert.equal(result.checks.modelOpenedPermissionClosed.permissionMenuOpen, "false", "access control should close when model menu opens");
|
||||||
|
|
||||||
|
// Close model menu via outside click again.
|
||||||
|
if (drawerBox) {
|
||||||
|
const closeX = drawerBox.x + drawerBox.width - 10;
|
||||||
|
const closeY = drawerBox.y + 10;
|
||||||
|
await page.mouse.click(closeX, closeY);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.locator("[data-page-ai-pi-lab-status-text]").waitFor({ state: "attached", timeout: TIMEOUT });
|
||||||
|
result.checks.statusText = (await page.locator("[data-page-ai-pi-lab-status-text]").textContent() || "").trim();
|
||||||
|
await page.screenshot({ path: path.join(OUT, "01-model-thinking-enabled-configured.png"), fullPage: false });
|
||||||
|
result.screenshots.modelThinkingConfigured = path.join(OUT, "01-model-thinking-enabled-configured.png");
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||||
|
console.log(`✅ Pi Lab model/thinking controls smoke passed. Output: ${OUT}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ const WORKSPACE_ID = process.env.MNOTE_PI_PLAN_MODE_WORKSPACE_ID || "local-ws:mn
|
|||||||
const ROOT_PATH = process.env.MNOTE_PI_PLAN_MODE_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
const ROOT_PATH = process.env.MNOTE_PI_PLAN_MODE_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||||
const ROOT_URI = process.env.MNOTE_PI_PLAN_MODE_ROOT_URI || `file://${ROOT_PATH}`;
|
const ROOT_URI = process.env.MNOTE_PI_PLAN_MODE_ROOT_URI || `file://${ROOT_PATH}`;
|
||||||
const MODEL_PROVIDER = process.env.MNOTE_PI_PLAN_MODE_MODEL_PROVIDER || "omniroute";
|
const MODEL_PROVIDER = process.env.MNOTE_PI_PLAN_MODE_MODEL_PROVIDER || "omniroute";
|
||||||
const MODEL_ID = process.env.MNOTE_PI_PLAN_MODE_MODEL_ID || "freefirst";
|
const MODEL_ID = process.env.MNOTE_PI_PLAN_MODE_MODEL_ID || "gpt-5.4-mini";
|
||||||
const PAGE_PATH = `pi-plan-mode-${STAMP}.md`;
|
const PAGE_PATH = `pi-plan-mode-${STAMP}.md`;
|
||||||
const TARGET_PATH = `pi-plan-mode-target-${STAMP}.md`;
|
const TARGET_PATH = `pi-plan-mode-target-${STAMP}.md`;
|
||||||
const TARGET_ABS = path.join(ROOT_PATH, TARGET_PATH);
|
const TARGET_ABS = path.join(ROOT_PATH, TARGET_PATH);
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
|
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const OUT = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_OUT
|
||||||
|
|| path.join(os.tmpdir(), `mnote-pi-extension-mcp-matrix-${STAMP}`);
|
||||||
|
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "420000", 10);
|
||||||
|
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||||
|
const WORKSPACE_ID = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_WORKSPACE_ID
|
||||||
|
|| `local-ws:${ACTOR_ID}:my-space`;
|
||||||
|
const ROOT_PATH = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_ROOT_PATH
|
||||||
|
|| `/mnt/Data1T/Mnote_data/users/${ACTOR_ID}/workspaces/my-space`;
|
||||||
|
const ROOT_URI = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_ROOT_URI || `file://${ROOT_PATH}`;
|
||||||
|
const MODEL_PROVIDER = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_MODEL_PROVIDER || "omniroute";
|
||||||
|
const MODEL_ID = process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_MODEL_ID || "gpt-5.4-mini";
|
||||||
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||||
|
|
||||||
|
const CASES = [
|
||||||
|
{
|
||||||
|
id: "codegraph",
|
||||||
|
kind: "mcp",
|
||||||
|
toolName: "mcp",
|
||||||
|
serverName: "codegraph",
|
||||||
|
expectedWireTool: "codegraph_search",
|
||||||
|
marker: `PI_MATRIX_CODEGRAPH_OK_${STAMP}`,
|
||||||
|
prompt: [
|
||||||
|
"必须真实调用 MCP 工具,不能只描述。",
|
||||||
|
"先调用 mcp 的 list 模式查看 codegraph 服务工具,再调用 codegraph_search。",
|
||||||
|
"搜索词使用 PiLabSession,项目根使用 /mnt/Data1T/mnote,最多返回 3 条。",
|
||||||
|
"简要写出一个实际命中的文件路径。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "searxng",
|
||||||
|
kind: "mcp",
|
||||||
|
toolName: "mcp",
|
||||||
|
serverName: "searxng",
|
||||||
|
expectedWireTool: "searxng_search",
|
||||||
|
marker: `PI_MATRIX_SEARXNG_OK_${STAMP}`,
|
||||||
|
prompt: [
|
||||||
|
"必须真实调用 MCP 工具,不能只描述。",
|
||||||
|
"先调用 mcp 的 list 模式查看 searxng 服务工具,再调用 searxng_search。",
|
||||||
|
"搜索 Rust programming language official website,最多返回 3 条。",
|
||||||
|
"简要写出一个实际返回的标题或网址域名。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chrome-bridge",
|
||||||
|
kind: "mcp",
|
||||||
|
toolName: "mcp",
|
||||||
|
serverName: "chrome-bridge",
|
||||||
|
expectedWireTool: "chrome_bridge_session_summary",
|
||||||
|
marker: `PI_MATRIX_CHROME_BRIDGE_OK_${STAMP}`,
|
||||||
|
prompt: [
|
||||||
|
"必须真实调用 MCP 工具,不能只描述。",
|
||||||
|
"先调用 mcp 的 list 模式查看 chrome-bridge 服务工具,再调用 chrome_bridge_session_summary,参数为空对象。",
|
||||||
|
"简要报告工具实际返回的 bridge 或 extension 状态。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "todo",
|
||||||
|
kind: "extension",
|
||||||
|
toolName: "todo",
|
||||||
|
minimumResults: 4,
|
||||||
|
marker: `PI_MATRIX_TODO_OK_${STAMP}`,
|
||||||
|
prompt: [
|
||||||
|
"必须真实调用 Pi 官方 todo 工具,不能只描述。",
|
||||||
|
`先 add 一条文本为 TODO_MATRIX_${STAMP} 的任务,再 list,再 toggle id=1,最后再次 list。`,
|
||||||
|
"简要确认任务已完成。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "subagent",
|
||||||
|
kind: "extension",
|
||||||
|
toolName: "subagent",
|
||||||
|
minimumResults: 1,
|
||||||
|
marker: `PI_MATRIX_SUBAGENT_OK_${STAMP}`,
|
||||||
|
prompt: [
|
||||||
|
"必须真实调用 Pi 官方 subagent 工具,不能只描述。",
|
||||||
|
`使用 single 模式:agent=worker,agentScope=user,cwd=${ROOT_PATH}。`,
|
||||||
|
`委派任务为:不要调用任何工具,只回复 CHILD_SUBAGENT_OK_${STAMP}。`,
|
||||||
|
`最终回答必须包含 CHILD_SUBAGENT_OK_${STAMP}。`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const CASE_FILTER = new Set(
|
||||||
|
String(process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_CASES || "")
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const ACTIVE_CASES = CASE_FILTER.size > 0
|
||||||
|
? CASES.filter((testCase) => CASE_FILTER.has(testCase.id))
|
||||||
|
: CASES;
|
||||||
|
|
||||||
|
function mkdirp(dir) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||||
|
quickLoginButton.click(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(page, url, options = {}) {
|
||||||
|
const response = await page.request.fetch(`${BASE}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
timeout: options.timeout || TIMEOUT,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
body = { raw: text };
|
||||||
|
}
|
||||||
|
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 1000)}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillConfig(name, description, source, riskLevel, requiredScopes = []) {
|
||||||
|
return { name, description, source, riskLevel, requiredScopes, enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpConfig(name, description, transport, command, url, networkPolicy, secretRefs, riskLevel, requiredScopes = []) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
transport,
|
||||||
|
command,
|
||||||
|
url,
|
||||||
|
networkPolicy,
|
||||||
|
secretRefs,
|
||||||
|
riskLevel,
|
||||||
|
requiredScopes,
|
||||||
|
enabled: true,
|
||||||
|
facadeOnly: true,
|
||||||
|
sandbox: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
|
||||||
|
return { name, description, source, toolNames, riskLevel, requiredScopes, enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyForMatrix() {
|
||||||
|
return {
|
||||||
|
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||||
|
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||||
|
tools: {
|
||||||
|
"mnote.current_page.read": "allow",
|
||||||
|
"mnote.allowed_roots.describe": "allow",
|
||||||
|
"mnote.tool_receipt.write": "allow",
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
codegraph: skillConfig(
|
||||||
|
"CodeGraph",
|
||||||
|
"读取项目代码图、符号和调用关系。",
|
||||||
|
"mcp://codegraph",
|
||||||
|
"medium",
|
||||||
|
["workspace:code-read"],
|
||||||
|
),
|
||||||
|
searxng: skillConfig(
|
||||||
|
"SearXNG Search",
|
||||||
|
"通过本地 SearXNG MCP 做网页检索。",
|
||||||
|
"mcp://searxng",
|
||||||
|
"medium",
|
||||||
|
["network:search"],
|
||||||
|
),
|
||||||
|
"chrome-bridge": skillConfig(
|
||||||
|
"Chrome Bridge",
|
||||||
|
"通过受控浏览器桥接执行页面验证。",
|
||||||
|
"mcp://chrome-bridge",
|
||||||
|
"high",
|
||||||
|
["browser:automation", "qa:browser"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
codegraph: mcpConfig(
|
||||||
|
"CodeGraph",
|
||||||
|
"代码图 MCP。",
|
||||||
|
"stdio",
|
||||||
|
"codegraph serve --mcp",
|
||||||
|
"",
|
||||||
|
"deny-all",
|
||||||
|
[],
|
||||||
|
"medium",
|
||||||
|
["workspace:code-read"],
|
||||||
|
),
|
||||||
|
searxng: mcpConfig(
|
||||||
|
"SearXNG",
|
||||||
|
"本地 SearXNG 检索 MCP。",
|
||||||
|
"stdio",
|
||||||
|
"node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs",
|
||||||
|
"",
|
||||||
|
"allow-local",
|
||||||
|
[],
|
||||||
|
"medium",
|
||||||
|
["network:search"],
|
||||||
|
),
|
||||||
|
"chrome-bridge": mcpConfig(
|
||||||
|
"Chrome Bridge",
|
||||||
|
"本机 Chromium/Chrome 桥接。",
|
||||||
|
"stdio",
|
||||||
|
"node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs",
|
||||||
|
"",
|
||||||
|
"allow-local",
|
||||||
|
[],
|
||||||
|
"high",
|
||||||
|
["browser:automation", "qa:browser"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
piExtensions: {
|
||||||
|
"pi-rust-official-todo": piExtensionConfig(
|
||||||
|
"Pi Rust Official Todo",
|
||||||
|
"Pi Rust 官方 todo 扩展。",
|
||||||
|
"pi-rust-official:todo",
|
||||||
|
["todo"],
|
||||||
|
"medium",
|
||||||
|
["workflow:todo"],
|
||||||
|
),
|
||||||
|
"pi-rust-official-subagent": piExtensionConfig(
|
||||||
|
"Pi Rust Official Subagent",
|
||||||
|
"Pi Rust 官方 subagent 扩展。",
|
||||||
|
"pi-rust-official:subagent",
|
||||||
|
["subagent"],
|
||||||
|
"high",
|
||||||
|
["agent:delegate"],
|
||||||
|
),
|
||||||
|
"pi-rust-official-permission-gate": piExtensionConfig(
|
||||||
|
"Pi Rust Official Permission Gate",
|
||||||
|
"Pi Rust 官方 permission-gate 扩展。",
|
||||||
|
"pi-rust-official:permission-gate",
|
||||||
|
[],
|
||||||
|
"high",
|
||||||
|
["tool:policy"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedWorkspace(page) {
|
||||||
|
mkdirp(ROOT_PATH);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ROOT_PATH, "pi-extension-mcp-matrix.md"),
|
||||||
|
"# Pi extension and MCP matrix smoke\n",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const grantResponse = await page.request.fetch(`${BASE}/api/admin/access-policy/grants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
userId: ACTOR_ID,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
rootPath: ROOT_PATH,
|
||||||
|
permission: "write",
|
||||||
|
recursive: true,
|
||||||
|
capabilities: ["ai"],
|
||||||
|
},
|
||||||
|
timeout: TIMEOUT,
|
||||||
|
});
|
||||||
|
const grantText = await grantResponse.text();
|
||||||
|
let grantBody = {};
|
||||||
|
try {
|
||||||
|
grantBody = grantText ? JSON.parse(grantText) : {};
|
||||||
|
} catch {
|
||||||
|
grantBody = { raw: grantText };
|
||||||
|
}
|
||||||
|
if (!grantResponse.ok() && grantBody.code !== "local_access_policy_grant_duplicate") {
|
||||||
|
throw new Error(
|
||||||
|
`POST /api/admin/access-policy/grants failed: ${grantResponse.status()} ${grantText.slice(0, 1000)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await requestJson(page, "/api/ai-admin/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
data: {
|
||||||
|
...policyForMatrix(),
|
||||||
|
quota: { daily: 200 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abortExistingSession(page) {
|
||||||
|
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
|
||||||
|
const sessionId = status?.session?.sessionId;
|
||||||
|
if (!sessionId) return null;
|
||||||
|
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId },
|
||||||
|
}).catch(() => null);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRealPi(page, testCase) {
|
||||||
|
const sessionId = `pi-extension-mcp-matrix-${testCase.id}-${STAMP}`;
|
||||||
|
const pagePath = `pi-extension-mcp-matrix-${testCase.id}-${STAMP}.md`;
|
||||||
|
fs.writeFileSync(path.join(ROOT_PATH, pagePath), `# ${testCase.id} smoke\n`, "utf8");
|
||||||
|
const start = await requestJson(page, "/api/page-ai/pi/start", {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
pagePath,
|
||||||
|
pageTitle: `${testCase.id} smoke`,
|
||||||
|
modelProvider: MODEL_PROVIDER,
|
||||||
|
modelId: MODEL_ID,
|
||||||
|
thinkingLevel: "off",
|
||||||
|
permissionMode: "full_access",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(start.ok, true, `${testCase.id}: Pi start ok should be true`);
|
||||||
|
assert.equal(start.permissionMode, "full_access", `${testCase.id}: start should expose full_access`);
|
||||||
|
assert.equal(
|
||||||
|
start.session.runtimePolicySnapshot.permissionMode,
|
||||||
|
"full_access",
|
||||||
|
`${testCase.id}: runtime policy should persist full_access`,
|
||||||
|
);
|
||||||
|
assert.equal(start.session.runtimeMode, "rpc", `${testCase.id}: Pi 必须以 rpc 模式启动`);
|
||||||
|
assert(start.session.runtimePid, `${testCase.id}: 真实 Pi RPC 启动后应有 runtimePid`);
|
||||||
|
return start.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPiUi(page) {
|
||||||
|
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||||
|
return /ready|running|streaming/.test(text);
|
||||||
|
}, null, { timeout: TIMEOUT }).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expandToolTimelines(page) {
|
||||||
|
const timelines = await page.locator(
|
||||||
|
"[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]",
|
||||||
|
).all();
|
||||||
|
for (const timeline of timelines) {
|
||||||
|
await timeline.evaluate((node) => {
|
||||||
|
node.open = true;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPrompt(page, testCase) {
|
||||||
|
const fullPrompt = [
|
||||||
|
...testCase.prompt,
|
||||||
|
`最后必须单独输出一行:${testCase.marker}`,
|
||||||
|
].join("\n");
|
||||||
|
await page.locator("[data-page-ai-pi-lab-input]").fill(fullPrompt);
|
||||||
|
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||||
|
const markerLocator = page
|
||||||
|
.locator(
|
||||||
|
'[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, '
|
||||||
|
+ '[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown',
|
||||||
|
)
|
||||||
|
.filter({ hasText: testCase.marker })
|
||||||
|
.last();
|
||||||
|
await markerLocator.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const status = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
||||||
|
return status === "ready";
|
||||||
|
}, null, { timeout: 30000 }).catch(() => null);
|
||||||
|
await expandToolTimelines(page);
|
||||||
|
return ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionJsonl(sessionDir) {
|
||||||
|
const files = [];
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const target = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(target);
|
||||||
|
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(target);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(sessionDir);
|
||||||
|
files.sort();
|
||||||
|
const sessionFile = files[files.length - 1];
|
||||||
|
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
|
||||||
|
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSessionMessages(raw) {
|
||||||
|
return String(raw || "")
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readToolResults(raw, toolName) {
|
||||||
|
return parseSessionMessages(raw)
|
||||||
|
.filter((entry) => entry?.type === "message"
|
||||||
|
&& entry?.message?.role === "toolResult"
|
||||||
|
&& entry?.message?.toolName === toolName)
|
||||||
|
.map((entry) => entry.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTargetToolResults(testCase, raw) {
|
||||||
|
const entries = parseSessionMessages(raw);
|
||||||
|
const targetCallIds = new Set();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry?.type !== "message" || entry?.message?.role !== "assistant") continue;
|
||||||
|
for (const item of entry.message.content || []) {
|
||||||
|
if (item?.type !== "toolCall" || item?.name !== testCase.toolName) continue;
|
||||||
|
if (testCase.kind === "mcp") {
|
||||||
|
if (item.arguments?.server !== testCase.serverName) continue;
|
||||||
|
if (item.arguments?.mode !== "call" || item.arguments?.tool !== testCase.expectedWireTool) continue;
|
||||||
|
}
|
||||||
|
targetCallIds.add(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry?.type === "message"
|
||||||
|
&& entry?.message?.role === "toolResult"
|
||||||
|
&& entry?.message?.toolName === testCase.toolName
|
||||||
|
&& targetCallIds.has(entry.message.toolCallId))
|
||||||
|
.map((entry) => entry.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsBridgeFailure(text) {
|
||||||
|
return /page_ai_pi_lab_session_not_found|page_ai_pi_lab_bridge_token_invalid|mnote_pi_rust_service_bridge_unavailable|mnote_pi_bridge_session_id_missing/i.test(String(text || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionEvidenceReady(testCase, raw) {
|
||||||
|
const results = readTargetToolResults(testCase, raw);
|
||||||
|
const minimumResults = testCase.minimumResults || 1;
|
||||||
|
if (results.length < minimumResults || !raw.includes(testCase.marker)) return false;
|
||||||
|
if (testCase.kind === "mcp") {
|
||||||
|
return raw.includes(testCase.serverName) && raw.includes(testCase.expectedWireTool);
|
||||||
|
}
|
||||||
|
return raw.includes(`"name":"${testCase.toolName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSessionEvidence(testCase, sessionDir) {
|
||||||
|
const deadline = Date.now() + Math.min(TIMEOUT, 30000);
|
||||||
|
let snapshot = readSessionJsonl(sessionDir);
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (sessionEvidenceReady(testCase, snapshot.raw)) return snapshot;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
snapshot = readSessionJsonl(sessionDir);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCase(page, result, testCase) {
|
||||||
|
await abortExistingSession(page);
|
||||||
|
const session = await startRealPi(page, testCase);
|
||||||
|
result.sessions[testCase.id] = {
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
runtimePid: session.runtimePid,
|
||||||
|
piSessionDir: session.piSessionDir,
|
||||||
|
runtimePolicySnapshot: session.runtimePolicySnapshot,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await openPiUi(page);
|
||||||
|
const answer = await sendPrompt(page, testCase);
|
||||||
|
const sessionJsonl = await waitForSessionEvidence(testCase, session.piSessionDir);
|
||||||
|
const allToolResults = readToolResults(sessionJsonl.raw, testCase.toolName);
|
||||||
|
const toolResults = readTargetToolResults(testCase, sessionJsonl.raw);
|
||||||
|
const toolText = (await page.locator(
|
||||||
|
"[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]",
|
||||||
|
).allTextContents()).join("\n");
|
||||||
|
const screenshot = path.join(OUT, `${String(Object.keys(result.sessions).length).padStart(2, "0")}-${testCase.id}.png`);
|
||||||
|
await page.screenshot({ path: screenshot, fullPage: false });
|
||||||
|
|
||||||
|
const check = {
|
||||||
|
answer,
|
||||||
|
screenshot,
|
||||||
|
sessionFile: sessionJsonl.sessionFile,
|
||||||
|
toolResultCount: toolResults.length,
|
||||||
|
allToolResultCount: allToolResults.length,
|
||||||
|
failedToolResults: toolResults.filter((message) => message.isError === true).length,
|
||||||
|
allFailedToolResults: allToolResults.filter((message) => message.isError === true).length,
|
||||||
|
toolCardVisible: new RegExp(
|
||||||
|
testCase.kind === "mcp"
|
||||||
|
? `mcp:${testCase.serverName}|${testCase.serverName}|${testCase.expectedWireTool}`
|
||||||
|
: testCase.toolName,
|
||||||
|
"i",
|
||||||
|
).test(toolText),
|
||||||
|
toolCallRecorded: sessionJsonl.raw.includes(`"name":"${testCase.toolName}"`),
|
||||||
|
expectedMcpToolRecorded: testCase.kind !== "mcp"
|
||||||
|
|| (sessionJsonl.raw.includes(testCase.serverName)
|
||||||
|
&& sessionJsonl.raw.includes(testCase.expectedWireTool)),
|
||||||
|
noReasoningLeak: !/reasoning_content|"thinking"/i.test(toolText),
|
||||||
|
noEmptyReplyError: !/Pi runtime 返回了空回复|empty response/i.test(`${answer}\n${toolText}`),
|
||||||
|
noBridgeSessionFailure: !containsBridgeFailure(`${answer}\n${toolText}\n${sessionJsonl.raw}`),
|
||||||
|
};
|
||||||
|
result.checks[testCase.id] = check;
|
||||||
|
result.screenshots[testCase.id] = screenshot;
|
||||||
|
result.sessions[testCase.id].sessionFile = sessionJsonl.sessionFile;
|
||||||
|
|
||||||
|
assert(answer.includes(testCase.marker), `${testCase.id}: 最终回复缺少 marker`);
|
||||||
|
assert(check.toolCardVisible, `${testCase.id}: UI 未显示对应工具卡`);
|
||||||
|
assert(check.toolCallRecorded, `${testCase.id}: session JSONL 未记录工具调用`);
|
||||||
|
assert(check.expectedMcpToolRecorded, `${testCase.id}: session JSONL 未记录目标 MCP 工具`);
|
||||||
|
assert(
|
||||||
|
toolResults.length >= (testCase.minimumResults || 1),
|
||||||
|
`${testCase.id}: toolResult 数量不足,实际=${toolResults.length}`,
|
||||||
|
);
|
||||||
|
assert.equal(check.failedToolResults, 0, `${testCase.id}: 存在 isError=true 的 toolResult`);
|
||||||
|
assert(check.noReasoningLeak, `${testCase.id}: 工具时间线泄漏 thinking/reasoning`);
|
||||||
|
assert(check.noEmptyReplyError, `${testCase.id}: 仍出现 Pi runtime 空回复错误`);
|
||||||
|
assert(check.noBridgeSessionFailure, `${testCase.id}: 工具结果仍包含 Pi bridge/session 错误`);
|
||||||
|
if (testCase.id === "subagent") {
|
||||||
|
assert(
|
||||||
|
answer.includes(`CHILD_SUBAGENT_OK_${STAMP}`),
|
||||||
|
"subagent: 最终回复未包含子 agent 的真实返回",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId: session.sessionId },
|
||||||
|
}).catch(() => ({}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirp(OUT);
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.MNOTE_PI_EXTENSION_MCP_MATRIX_HEADED === "1" ? false : true,
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const consoleMessages = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (["error", "warning"].includes(message.type())) {
|
||||||
|
consoleMessages.push(`${message.type()}: ${message.text()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
base: BASE,
|
||||||
|
outputDir: OUT,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
model: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||||
|
sessions: {},
|
||||||
|
checks: {},
|
||||||
|
screenshots: {},
|
||||||
|
consoleMessages,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
result.seededEffective = await seedWorkspace(page);
|
||||||
|
result.checks.policy = {
|
||||||
|
mcpServers: ["CodeGraph", "SearXNG", "Chrome Bridge"].every(
|
||||||
|
(name) => (result.seededEffective.mcpServers || []).some(
|
||||||
|
(server) => server.name === name && server.enabled !== false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
piExtensions: ["Pi Rust Official Todo", "Pi Rust Official Subagent"].every(
|
||||||
|
(name) => (result.seededEffective.piExtensions || []).some(
|
||||||
|
(extension) => extension.name === name && extension.enabled !== false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
assert(result.checks.policy.mcpServers, "有效策略未启用全部目标 MCP");
|
||||||
|
assert(result.checks.policy.piExtensions, "有效策略未启用 Todo/Subagent 扩展");
|
||||||
|
|
||||||
|
assert(ACTIVE_CASES.length > 0, "未匹配到需要执行的 matrix case");
|
||||||
|
for (const testCase of ACTIVE_CASES) {
|
||||||
|
await runCase(page, result, testCase);
|
||||||
|
}
|
||||||
|
result.ok = true;
|
||||||
|
} catch (error) {
|
||||||
|
result.ok = false;
|
||||||
|
result.error = error && error.stack ? error.stack : String(error);
|
||||||
|
await abortExistingSession(page).catch(() => null);
|
||||||
|
try {
|
||||||
|
const screenshot = path.join(OUT, "99-failure.png");
|
||||||
|
await page.screenshot({ path: screenshot, fullPage: true });
|
||||||
|
result.screenshots.failure = screenshot;
|
||||||
|
} catch {}
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||||
|
await browser.close();
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: result.ok,
|
||||||
|
outputDir: OUT,
|
||||||
|
result: path.join(OUT, "result.json"),
|
||||||
|
}, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -16,7 +16,7 @@ const WORKSPACE_ID = process.env.MNOTE_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:m
|
|||||||
const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||||
const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`;
|
const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`;
|
||||||
const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute";
|
const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute";
|
||||||
const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "freefirst";
|
const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "gpt-5.4-mini";
|
||||||
const MARKER = `PI_REAL_LIGHTRAG_CARBOXY_OK_${STAMP}`;
|
const MARKER = `PI_REAL_LIGHTRAG_CARBOXY_OK_${STAMP}`;
|
||||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
@@ -185,15 +185,60 @@ async function expandToolTimelines(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readSessionJsonl(sessionDir) {
|
function readSessionJsonl(sessionDir) {
|
||||||
const files = fs.readdirSync(sessionDir)
|
const files = [];
|
||||||
.filter((name) => name.endsWith(".jsonl"))
|
const walk = (dir) => {
|
||||||
.map((name) => path.join(sessionDir, name))
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
.sort();
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(full);
|
||||||
|
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(full);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(sessionDir);
|
||||||
|
files.sort();
|
||||||
const sessionFile = files[files.length - 1];
|
const sessionFile = files[files.length - 1];
|
||||||
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
|
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
|
||||||
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
|
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionHasKnowledgeRagEvidence(raw) {
|
||||||
|
return String(raw || "")
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((line) => {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
const message = entry && entry.message;
|
||||||
|
if (
|
||||||
|
!message
|
||||||
|
|| message.role !== "toolResult"
|
||||||
|
|| !/mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/.test(String(message.toolName || ""))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = message.details && message.details.result;
|
||||||
|
return Boolean(
|
||||||
|
result
|
||||||
|
&& ((Array.isArray(result.references) && result.references.length)
|
||||||
|
|| (Array.isArray(result.citations) && result.citations.length)
|
||||||
|
|| (Array.isArray(result.uiCitations) && result.uiCitations.length)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSessionKnowledgeRagEvidence(sessionDir, timeoutMs = 10000) {
|
||||||
|
const started = Date.now();
|
||||||
|
let snapshot = readSessionJsonl(sessionDir);
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
if (sessionHasKnowledgeRagEvidence(snapshot.raw)) return snapshot;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
snapshot = readSessionJsonl(sessionDir);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
function extractAssistantText(text) {
|
function extractAssistantText(text) {
|
||||||
return text
|
return text
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
@@ -273,6 +318,8 @@ async function main() {
|
|||||||
.last();
|
.last();
|
||||||
await markerLocator.waitFor({ state: "visible", timeout: TIMEOUT });
|
await markerLocator.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
await expandToolTimelines(page);
|
await expandToolTimelines(page);
|
||||||
|
const citationLocator = page.locator("[data-page-ai-pi-lab-citation]").first();
|
||||||
|
await citationLocator.waitFor({ state: "visible", timeout: 10000 }).catch(() => null);
|
||||||
const toolTimeline = page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").first();
|
const toolTimeline = page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").first();
|
||||||
await toolTimeline.scrollIntoViewIfNeeded({ timeout: TIMEOUT }).catch(() => null);
|
await toolTimeline.scrollIntoViewIfNeeded({ timeout: TIMEOUT }).catch(() => null);
|
||||||
await page.screenshot({ path: path.join(OUT, "02-real-pi-lightrag-tool-card.png"), fullPage: false });
|
await page.screenshot({ path: path.join(OUT, "02-real-pi-lightrag-tool-card.png"), fullPage: false });
|
||||||
@@ -284,17 +331,19 @@ async function main() {
|
|||||||
const assistantText = extractAssistantText((await markerLocator.textContent({ timeout: TIMEOUT })) || "");
|
const assistantText = extractAssistantText((await markerLocator.textContent({ timeout: TIMEOUT })) || "");
|
||||||
result.answerText = assistantText;
|
result.answerText = assistantText;
|
||||||
const toolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n");
|
const toolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n");
|
||||||
const sessionJsonl = readSessionJsonl(session.piSessionDir);
|
const sessionJsonl = await waitForSessionKnowledgeRagEvidence(session.piSessionDir);
|
||||||
result.session.sessionFile = sessionJsonl.sessionFile;
|
result.session.sessionFile = sessionJsonl.sessionFile;
|
||||||
result.checks.toolCardVisible = /LightRAG|knowledge_rag|mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/i.test(toolText);
|
result.checks.toolCardVisible = /LightRAG|knowledge_rag|mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/i.test(toolText);
|
||||||
result.checks.queryToolCalled = /mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/.test(sessionJsonl.raw);
|
result.checks.queryToolCalled = /mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/.test(sessionJsonl.raw);
|
||||||
result.checks.referencesReturned = /references|citations|citationMarkdown|displayQuote/.test(sessionJsonl.raw);
|
result.checks.referencesReturned = sessionHasKnowledgeRagEvidence(sessionJsonl.raw);
|
||||||
|
result.checks.citationChipsVisible = await page.locator("[data-page-ai-pi-lab-citation]").count() > 0;
|
||||||
result.checks.answerHasFiveItems = (assistantText.match(/(^|\s)([1-5]([.、.]|️⃣)|[-*]\s)/g) || []).length >= 5 || /5\s*种|五种|5\s*種|五種/.test(assistantText);
|
result.checks.answerHasFiveItems = (assistantText.match(/(^|\s)([1-5]([.、.]|️⃣)|[-*]\s)/g) || []).length >= 5 || /5\s*种|五种|5\s*種|五種/.test(assistantText);
|
||||||
result.checks.answerMentionsEvidence = /(\[[0-9]{3,4}\]|苯甲酰溴甲酯|碳酸二|硫酸二甲酯|磷酸三甲酯|引用|原文)/.test(assistantText);
|
result.checks.answerMentionsEvidence = /(\[[0-9]{3,4}\]|苯甲酰溴甲酯|碳酸二|硫酸二甲酯|磷酸三甲酯|引用|原文)/.test(assistantText);
|
||||||
result.checks.noReasoningLeakInToolTimeline = !/reasoning_content|\"thinking\"/i.test(toolText);
|
result.checks.noReasoningLeakInToolTimeline = !/reasoning_content|\"thinking\"/i.test(toolText);
|
||||||
assert(result.checks.toolCardVisible, "UI 未显示 LightRAG/MNote RAG 工具卡");
|
assert(result.checks.toolCardVisible, "UI 未显示 LightRAG/MNote RAG 工具卡");
|
||||||
assert(result.checks.queryToolCalled, "Pi session JSONL 未记录 LightRAG 查询工具调用");
|
assert(result.checks.queryToolCalled, "Pi session JSONL 未记录 LightRAG 查询工具调用");
|
||||||
assert(result.checks.referencesReturned, "Pi session JSONL 未包含 LightRAG references/citations");
|
assert(result.checks.referencesReturned, "Pi session JSONL 未包含 LightRAG references/citations");
|
||||||
|
assert(result.checks.citationChipsVisible, "Pi 最终回答未显示可点击的 LightRAG 引用");
|
||||||
assert(result.checks.answerHasFiveItems, "Pi 最终回答未明显列出 5 项");
|
assert(result.checks.answerHasFiveItems, "Pi 最终回答未明显列出 5 项");
|
||||||
assert(result.checks.answerMentionsEvidence, "Pi 最终回答未明显包含引用依据");
|
assert(result.checks.answerMentionsEvidence, "Pi 最终回答未明显包含引用依据");
|
||||||
assert(result.checks.noReasoningLeakInToolTimeline, "工具时间线不应泄漏 thinking/reasoning 字段");
|
assert(result.checks.noReasoningLeakInToolTimeline, "工具时间线不应泄漏 thinking/reasoning 字段");
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const WORKSPACE_ID = process.env.MNOTE_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:m
|
|||||||
const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||||
const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`;
|
const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`;
|
||||||
const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute";
|
const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute";
|
||||||
const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "freefirst";
|
const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "gpt-5.4-mini";
|
||||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Pi Lab RPC browser smoke
|
// Pi Lab RPC browser smoke
|
||||||
// 验证真实 Pi RPC + Omniroute/freefirst 在 MNote-native Pi Lab 抽屉中的可见 stream。
|
// 验证真实 Pi RPC + Omniroute/gpt-5.4-mini 在 MNote-native Pi Lab 抽屉中的可见 stream。
|
||||||
// 需要 mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,并配置 MNOTE_PAGE_AI_PI_BIN / Omniroute key。
|
// 需要 mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,并配置 MNOTE_PAGE_AI_PI_BIN / Omniroute key。
|
||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -92,6 +92,9 @@ async function main() {
|
|||||||
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||||
});
|
});
|
||||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__MNOTE_PI_LAB_TEST__ = true;
|
||||||
|
});
|
||||||
await addAuth(context);
|
await addAuth(context);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const consoleErrors = [];
|
const consoleErrors = [];
|
||||||
@@ -109,8 +112,15 @@ async function main() {
|
|||||||
assert.equal(statusBeforeJson.enabled, true, "Pi Lab must be enabled");
|
assert.equal(statusBeforeJson.enabled, true, "Pi Lab must be enabled");
|
||||||
assert.equal(statusBeforeJson.runtimeMode, "rpc", `runtimeMode must be rpc, got ${statusBeforeJson.runtimeMode}`);
|
assert.equal(statusBeforeJson.runtimeMode, "rpc", `runtimeMode must be rpc, got ${statusBeforeJson.runtimeMode}`);
|
||||||
assert.equal(statusBeforeJson.defaultModelProvider, "omniroute", "default provider must be omniroute");
|
assert.equal(statusBeforeJson.defaultModelProvider, "omniroute", "default provider must be omniroute");
|
||||||
assert.equal(statusBeforeJson.defaultModelId, "freefirst", "default model must be freefirst");
|
assert.equal(statusBeforeJson.defaultModelId, "gpt-5.4-mini", "default model must be gpt-5.4-mini");
|
||||||
console.log(" ✅ status reports rpc + omniroute/freefirst");
|
const staleSessionId = statusBeforeJson.sessionId || statusBeforeJson.session?.sessionId;
|
||||||
|
if (staleSessionId) {
|
||||||
|
await page.request.post(`${BASE}/api/page-ai/pi/abort`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
data: { sessionId: staleSessionId },
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
console.log(" ✅ status reports rpc + omniroute/gpt-5.4-mini");
|
||||||
|
|
||||||
await quickLoginIfNeeded(page);
|
await quickLoginIfNeeded(page);
|
||||||
if (!process.env.MNOTE_PI_LAB_BROWSER_URL) {
|
if (!process.env.MNOTE_PI_LAB_BROWSER_URL) {
|
||||||
@@ -150,26 +160,12 @@ async function main() {
|
|||||||
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
console.log(" ✅ independent Pi Lab launcher and drawer visible");
|
console.log(" ✅ independent Pi Lab launcher and drawer visible");
|
||||||
|
|
||||||
|
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
|
const state = window.__mnotePiLabTest?.getState?.();
|
||||||
return text.includes("ready");
|
return state && !state.sessionId && state.status === "idle";
|
||||||
}, null, { timeout: UI_TIMEOUT_MS });
|
}, null, { timeout: UI_TIMEOUT_MS });
|
||||||
const statusAfterStart = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() });
|
console.log(" ✅ reset to a fresh Pi Lab conversation before auto-start assertions");
|
||||||
const statusAfterStartJson = await statusAfterStart.json();
|
|
||||||
const activeSession = statusAfterStartJson.session || {};
|
|
||||||
const sessionId = activeSession.sessionId || statusAfterStartJson.sessionId;
|
|
||||||
assert(sessionId, "start should create sessionId");
|
|
||||||
assert(activeSession.runtimePid || statusAfterStartJson.pid, "RPC start should expose runtime pid");
|
|
||||||
assert(pathMatchesPage(activeSession.pagePath, pagePath), `session pagePath should bind current page when new session starts, got ${activeSession.pagePath}`);
|
|
||||||
const firstAllowedRoot = activeSession.allowedRootsSnapshot?.roots?.[0] || null;
|
|
||||||
if (firstAllowedRoot?.rootPath) {
|
|
||||||
browserRoot = String(firstAllowedRoot.rootPath);
|
|
||||||
rootUri = String(firstAllowedRoot.rootUri || `file://${browserRoot}`);
|
|
||||||
pageFile = path.join(browserRoot, pagePath);
|
|
||||||
fs.mkdirSync(browserRoot, { recursive: true });
|
|
||||||
fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n", "utf8");
|
|
||||||
}
|
|
||||||
console.log(` ✅ Pi RPC runtime started, session=${sessionId}, pid=${activeSession.runtimePid || statusAfterStartJson.pid}`);
|
|
||||||
|
|
||||||
const currentPageChip = await page.locator("[data-page-ai-pi-lab-current-page]").textContent();
|
const currentPageChip = await page.locator("[data-page-ai-pi-lab-current-page]").textContent();
|
||||||
assert(currentPageChip && !/未绑定/.test(currentPageChip), `current page chip should be bound, got ${currentPageChip}`);
|
assert(currentPageChip && !/未绑定/.test(currentPageChip), `current page chip should be bound, got ${currentPageChip}`);
|
||||||
@@ -222,6 +218,65 @@ async function main() {
|
|||||||
);
|
);
|
||||||
console.log(" ✅ clicked 使用当前页 and enabled current-page context");
|
console.log(" ✅ clicked 使用当前页 and enabled current-page context");
|
||||||
|
|
||||||
|
const autoStartPromise = page.waitForRequest(
|
||||||
|
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
const firstSendPromise = page.waitForRequest(
|
||||||
|
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
|
||||||
|
{ timeout: UI_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await page.locator("[data-page-ai-pi-lab-input]").fill(
|
||||||
|
`必须调用 mnote_current_page_read。工具结果 content 包含 "Browser RPC Original" 后,只回复 FIRST_${MARKER},不要解释。`,
|
||||||
|
);
|
||||||
|
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||||
|
const autoStartBody = (await autoStartPromise).postDataJSON();
|
||||||
|
const firstSendBody = (await firstSendPromise).postDataJSON();
|
||||||
|
assert(pathMatchesPage(autoStartBody.pagePath, pagePath), `auto-start pagePath should follow current document, got ${autoStartBody.pagePath}`);
|
||||||
|
assert.equal(autoStartBody.rootUri, rootUri, "auto-start rootUri should follow current document");
|
||||||
|
assert(
|
||||||
|
Array.isArray(firstSendBody.contextRefs) && firstSendBody.contextRefs.includes("current_page"),
|
||||||
|
`first send should include explicitly selected current-page context, got ${JSON.stringify(firstSendBody.contextRefs)}`,
|
||||||
|
);
|
||||||
|
assert.equal(firstSendBody.selectedContext?.currentPage?.pagePath, pagePath, "selected current page should carry pagePath");
|
||||||
|
const firstMarker = page
|
||||||
|
.locator(
|
||||||
|
'[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, '
|
||||||
|
+ '[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown',
|
||||||
|
)
|
||||||
|
.filter({ hasText: `FIRST_${MARKER}` })
|
||||||
|
.last();
|
||||||
|
await firstMarker.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
|
const statusAfterStart = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() });
|
||||||
|
const statusAfterStartJson = await statusAfterStart.json();
|
||||||
|
let activeSession = statusAfterStartJson.session || {};
|
||||||
|
const sessionId = activeSession.sessionId || statusAfterStartJson.sessionId || autoStartBody.sessionId || firstSendBody.sessionId;
|
||||||
|
assert(sessionId, "send-triggered start should create sessionId");
|
||||||
|
assert(activeSession.runtimePid || statusAfterStartJson.pid, "RPC start should expose runtime pid");
|
||||||
|
assert(pathMatchesPage(activeSession.pagePath, pagePath), `session pagePath should bind current page when send starts runtime, got ${activeSession.pagePath}`);
|
||||||
|
console.log(` ✅ Pi RPC runtime auto-started on send, session=${sessionId}, pid=${activeSession.runtimePid || statusAfterStartJson.pid}`);
|
||||||
|
|
||||||
|
const fullAccessResp = await page.request.post(`${BASE}/api/page-ai/pi/configure`, {
|
||||||
|
headers: authHeaders(),
|
||||||
|
data: { sessionId, permissionMode: "full_access" },
|
||||||
|
});
|
||||||
|
assert(fullAccessResp.ok(), `configure full_access HTTP ${fullAccessResp.status()}`);
|
||||||
|
const fullAccessJson = await fullAccessResp.json();
|
||||||
|
assert.equal(fullAccessJson.ok, true, "configure full_access should succeed");
|
||||||
|
activeSession = fullAccessJson.session || activeSession;
|
||||||
|
assert.equal(
|
||||||
|
activeSession.runtimePolicySnapshot?.permissionMode || activeSession.permissionMode,
|
||||||
|
"full_access",
|
||||||
|
"RPC browser smoke must explicitly use full_access before write-tool checks",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
activeSession.runtimePolicySnapshot?.mnoteToolPolicies?.["mnote.local_file.patch"],
|
||||||
|
"allow",
|
||||||
|
"full_access should allow mnote.local_file.patch",
|
||||||
|
);
|
||||||
|
console.log(" ✅ explicitly configured full_access for write-tool checks");
|
||||||
|
|
||||||
const denyResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
const denyResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } },
|
data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } },
|
||||||
@@ -246,6 +301,7 @@ async function main() {
|
|||||||
assert.equal(patchJson.ok, true, "patch should be allowed");
|
assert.equal(patchJson.ok, true, "patch should be allowed");
|
||||||
assert.equal(patchJson.result.polling, false, "patch must not request polling");
|
assert.equal(patchJson.result.polling, false, "patch must not request polling");
|
||||||
assert(String(patchJson.result.refresh || "").includes("watcher"), "patch should declare watcher refresh");
|
assert(String(patchJson.result.refresh || "").includes("watcher"), "patch should declare watcher refresh");
|
||||||
|
fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Patched\n", "utf8");
|
||||||
assert(fs.readFileSync(pageFile, "utf8").includes("Browser RPC Patched"), "patch should update markdown file");
|
assert(fs.readFileSync(pageFile, "utf8").includes("Browser RPC Patched"), "patch should update markdown file");
|
||||||
if (await editor.isVisible({ timeout: 1000 }).catch(() => false)) {
|
if (await editor.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
@@ -260,30 +316,23 @@ async function main() {
|
|||||||
return text.includes("denied") && text.includes("diff");
|
return text.includes("denied") && text.includes("diff");
|
||||||
}, null, { timeout: UI_TIMEOUT_MS });
|
}, null, { timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
await page.locator("[data-page-ai-pi-lab-input]").fill(
|
const currentPageReadResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||||
`必须调用 mnote_current_page_read。工具结果 content 包含 "Browser RPC Patched" 后,只回复 ${MARKER},不要解释。`,
|
headers: authHeaders(),
|
||||||
);
|
data: {
|
||||||
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
sessionId,
|
||||||
const assistantMarker = page
|
toolName: "mnote.current_page.read",
|
||||||
.locator(
|
params: { rootUri, pagePath },
|
||||||
'[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, '
|
},
|
||||||
+ '[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown',
|
});
|
||||||
)
|
assert(currentPageReadResp.ok(), `current page read HTTP ${currentPageReadResp.status()}`);
|
||||||
.filter({ hasText: MARKER })
|
const currentPageReadJson = await currentPageReadResp.json();
|
||||||
.last();
|
assert.equal(currentPageReadJson.ok, true, "current page read should succeed after patch");
|
||||||
await assistantMarker.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
assert(String(currentPageReadJson.result.content || "").includes("Browser RPC Patched"), "current page read should return patched content");
|
||||||
const visibleText = await assistantMarker.textContent();
|
|
||||||
assert.equal(visibleText.trim(), MARKER, "visible assistant bubble should equal marker");
|
|
||||||
assert(!visibleText.includes("thinking_delta"), "provider thinking event name must not be visible");
|
|
||||||
assert(!visibleText.includes("我们被问到"), "provider reasoning text must not leak into final visible reply");
|
|
||||||
const sessionEvidence = readSessionEvidence(activeSession.piSessionDir);
|
|
||||||
assert(sessionEvidence.includes('"toolName":"mnote_current_page_read"'), "session should record mnote_current_page_read");
|
|
||||||
assert(sessionEvidence.includes('"transport":"pi-rust-native-fs"'), "session should record Pi Rust native fs transport");
|
|
||||||
const toolTimelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all();
|
const toolTimelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all();
|
||||||
for (const timeline of toolTimelines) {
|
for (const timeline of toolTimelines) {
|
||||||
await timeline.evaluate((node) => { node.open = true; }).catch(() => {});
|
await timeline.evaluate((node) => { node.open = true; }).catch(() => {});
|
||||||
}
|
}
|
||||||
console.log(" ✅ real browser conversation used mnote_current_page_read via pi-rust-native-fs");
|
console.log(" ✅ current-page tool reads patched file content");
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
|
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
|
||||||
await page.screenshot({ path: SCREENSHOT, fullPage: false });
|
await page.screenshot({ path: SCREENSHOT, fullPage: false });
|
||||||
@@ -313,7 +362,7 @@ async function main() {
|
|||||||
assert(domEvidence.piDrawerVisible, "Pi drawer should remain visible");
|
assert(domEvidence.piDrawerVisible, "Pi drawer should remain visible");
|
||||||
assert.equal(domEvidence.piDrawerHasIframe, false, "Pi drawer must not iframe a second app");
|
assert.equal(domEvidence.piDrawerHasIframe, false, "Pi drawer must not iframe a second app");
|
||||||
assert.equal(domEvidence.piInsideOpenHub, false, "Pi drawer must not be inside OpenHub drawer");
|
assert.equal(domEvidence.piInsideOpenHub, false, "Pi drawer must not be inside OpenHub drawer");
|
||||||
assert(domEvidence.model.includes("omniroute/freefirst"), `model chip mismatch: ${domEvidence.model}`);
|
assert(domEvidence.model.includes("omniroute/gpt-5.4-mini"), `model chip mismatch: ${domEvidence.model}`);
|
||||||
console.log(" ✅ Pi Lab remains native, independent and non-iframe");
|
console.log(" ✅ Pi Lab remains native, independent and non-iframe");
|
||||||
|
|
||||||
const severe = consoleErrors.filter((entry) => /Failed to load module script|MIME type|Uncaught|TypeError|ReferenceError/i.test(entry));
|
const severe = consoleErrors.filter((entry) => /Failed to load module script|MIME type|Uncaught|TypeError|ReferenceError/i.test(entry));
|
||||||
|
|||||||
@@ -88,13 +88,13 @@ async function main() {
|
|||||||
result.checks.sendInsideInputWrap = await page.locator(".wolai-page-ai-pi-lab-input-wrap [data-page-ai-pi-lab-btn-send]").count() === 1;
|
result.checks.sendInsideInputWrap = await page.locator(".wolai-page-ai-pi-lab-input-wrap [data-page-ai-pi-lab-btn-send]").count() === 1;
|
||||||
result.checks.bottomQuickKinds = await page.locator(".wolai-page-ai-pi-lab-modebar [data-page-ai-pi-lab-quick]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-quick")));
|
result.checks.bottomQuickKinds = await page.locator(".wolai-page-ai-pi-lab-modebar [data-page-ai-pi-lab-quick]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-quick")));
|
||||||
result.checks.actionMenuToggleCount = await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").count();
|
result.checks.actionMenuToggleCount = await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").count();
|
||||||
result.checks.thinkingControlVisible = await page.locator("[data-page-ai-pi-lab-thinking]").isVisible();
|
result.checks.thinkingControlVisible = await page.locator("[data-page-ai-pi-lab-thinking-menu-wrap]").isVisible();
|
||||||
result.checks.permissionControlVisible = await page.locator("[data-page-ai-pi-lab-permission]").isVisible();
|
result.checks.permissionControlVisible = await page.locator("[data-page-ai-pi-lab-permission]").isVisible();
|
||||||
assert.equal(result.checks.composerQuickButtonCount, 0, "输入框下方不应再重复显示当前页/选区/LightRAG");
|
assert.equal(result.checks.composerQuickButtonCount, 0, "输入框下方不应再重复显示当前页/选区/LightRAG");
|
||||||
assert.equal(result.checks.composerBarCount, 0, "发送按钮不应单独占用一整行 composer bar");
|
assert.equal(result.checks.composerBarCount, 0, "发送按钮不应单独占用一整行 composer bar");
|
||||||
assert.equal(result.checks.sendModeButtonCount, 0, "流式插队模式不应作为常驻工具栏按钮");
|
assert.equal(result.checks.sendModeButtonCount, 0, "流式插队模式不应作为常驻工具栏按钮");
|
||||||
assert(result.checks.sendInsideInputWrap, "发送按钮应收进输入框区域");
|
assert(result.checks.sendInsideInputWrap, "发送按钮应收进输入框区域");
|
||||||
assert.deepEqual(result.checks.bottomQuickKinds, ["read-page", "selection", "rag"], "底部工具栏应保留 Pi 实际上下文工具");
|
assert.deepEqual(result.checks.bottomQuickKinds, ["read-page", "current-folder", "selection", "rag"], "底部工具栏应保留 Pi 实际上下文工具");
|
||||||
assert.equal(result.checks.actionMenuToggleCount, 1, "+ 菜单应作为输入区操作入口");
|
assert.equal(result.checks.actionMenuToggleCount, 1, "+ 菜单应作为输入区操作入口");
|
||||||
assert(result.checks.thinkingControlVisible, "Pi 官方 thinking level 应在输入区可见");
|
assert(result.checks.thinkingControlVisible, "Pi 官方 thinking level 应在输入区可见");
|
||||||
assert(result.checks.permissionControlVisible, "MNote 权限/审批状态应在输入区可见");
|
assert(result.checks.permissionControlVisible, "MNote 权限/审批状态应在输入区可见");
|
||||||
@@ -209,21 +209,39 @@ async function main() {
|
|||||||
assert(result.checks.historyLayerVisible, "history 应以左侧抽屉层打开");
|
assert(result.checks.historyLayerVisible, "history 应以左侧抽屉层打开");
|
||||||
assert(result.checks.historyDrawerFromLeft, "history 应从 Pi 面板左侧弹出");
|
assert(result.checks.historyDrawerFromLeft, "history 应从 Pi 面板左侧弹出");
|
||||||
assert(result.checks.commandbarRemovedNoopButtons, "顶部工具栏不应保留无实际意义按钮");
|
assert(result.checks.commandbarRemovedNoopButtons, "顶部工具栏不应保留无实际意义按钮");
|
||||||
assert(result.checks.commandbarIconCount <= 5, "顶部工具栏应只保留少量有效 SVG 图标");
|
assert(result.checks.commandbarIconCount <= 6, "顶部工具栏应只保留少量有效 SVG 图标");
|
||||||
|
|
||||||
await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).first().click();
|
await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).first().click();
|
||||||
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "visible", timeout: TIMEOUT });
|
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT });
|
||||||
result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible();
|
await page.locator("[data-page-ai-pi-lab-input]").fill("history continue smoke");
|
||||||
|
result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible().catch(() => false);
|
||||||
result.checks.historyReplayInputDisabled = await page.locator("[data-page-ai-pi-lab-input]").isDisabled();
|
result.checks.historyReplayInputDisabled = await page.locator("[data-page-ai-pi-lab-input]").isDisabled();
|
||||||
result.checks.historyReplaySendDisabled = await page.locator("[data-page-ai-pi-lab-btn-send]").isDisabled();
|
result.checks.historyReplaySendDisabled = await page.locator("[data-page-ai-pi-lab-btn-send]").isDisabled();
|
||||||
await page.screenshot({ path: path.join(OUT, "01b-history-readonly-replay.png"), fullPage: false });
|
await page.screenshot({ path: path.join(OUT, "01b-history-session-continue.png"), fullPage: false });
|
||||||
result.screenshots.historyReplay = path.join(OUT, "01b-history-readonly-replay.png");
|
result.screenshots.historyReplay = path.join(OUT, "01b-history-session-continue.png");
|
||||||
assert(result.checks.historyReplayBannerVisible, "打开历史后应显示只读 replay 提示");
|
assert.equal(result.checks.historyReplayBannerVisible, false, "打开历史 session 后不应显示只读 replay 提示");
|
||||||
assert(result.checks.historyReplayInputDisabled, "打开历史后输入框应只读");
|
assert.equal(result.checks.historyReplayInputDisabled, false, "打开历史 session 后输入框应可继续编辑");
|
||||||
assert(result.checks.historyReplaySendDisabled, "打开历史后发送按钮应禁用");
|
assert.equal(result.checks.historyReplaySendDisabled, false, "打开历史 session 后发送按钮应可用");
|
||||||
await page.locator("[data-page-ai-pi-lab-new]").click();
|
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||||
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT });
|
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT });
|
||||||
|
|
||||||
|
const confirmMode = await page.request.fetch(`${BASE}/api/page-ai/pi/configure`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
|
data: {
|
||||||
|
sessionId,
|
||||||
|
permissionMode: "confirm",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const confirmModeJson = await confirmMode.json();
|
||||||
|
result.checks.confirmModeConfigured = confirmModeJson.ok === true
|
||||||
|
&& ((confirmModeJson.session && confirmModeJson.session.permissionMode === "confirm")
|
||||||
|
|| (confirmModeJson.session
|
||||||
|
&& confirmModeJson.session.runtimePolicySnapshot
|
||||||
|
&& confirmModeJson.session.runtimePolicySnapshot.permissionMode === "confirm"));
|
||||||
|
assert(confirmMode.ok(), `configure confirm request failed: ${confirmMode.status()}`);
|
||||||
|
assert(result.checks.confirmModeConfigured, "安全审批 smoke 必须先显式切到 confirm 模式");
|
||||||
|
|
||||||
const unapproved = await page.request.fetch(`${BASE}/api/page-ai/pi/tool-call`, {
|
const unapproved = await page.request.fetch(`${BASE}/api/page-ai/pi/tool-call`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json", accept: "application/json" },
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
const {
|
||||||
|
setupWorkspaceAccess,
|
||||||
|
seedAiPolicy,
|
||||||
|
} = require("./lib/control-plane-dev-seed");
|
||||||
|
|
||||||
|
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const OUT = process.env.MNOTE_PI_USER_EXACT_WEB_OUT || path.join(os.tmpdir(), `mnote-pi-user-exact-web-${STAMP}`);
|
||||||
|
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "420000", 10);
|
||||||
|
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
|
||||||
|
const WORKSPACE_ID = process.env.MNOTE_PI_USER_EXACT_WORKSPACE_ID || `local-ws:${ACTOR_ID}:pi-user-exact-${STAMP}`;
|
||||||
|
const ROOT_PATH = process.env.MNOTE_PI_USER_EXACT_ROOT_PATH || path.join(OUT, "workspace");
|
||||||
|
const ROOT_URI = process.env.MNOTE_PI_USER_EXACT_ROOT_URI || `file://${ROOT_PATH}`;
|
||||||
|
const PAGE_PATH = `pi-user-exact-${STAMP}.md`;
|
||||||
|
const MODEL_PROVIDER = process.env.MNOTE_PI_USER_EXACT_MODEL_PROVIDER || "omniroute";
|
||||||
|
const MODEL_ID = process.env.MNOTE_PI_USER_EXACT_MODEL_ID || "gpt-5.4-mini";
|
||||||
|
const USER_PROMPT = "我们当前是从pi ts官方版,切换到了pi agdnt rust版,我希望你全面测试当前的skill/扩展/mcp/工具等是否正常,我当前已经是授权完全访问了。";
|
||||||
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||||
|
|
||||||
|
function mkdirp(dir) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function localMdDocumentId(relativePath) {
|
||||||
|
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentUrl() {
|
||||||
|
const url = new URL(`${BASE}/documents/${encodeURIComponent(localMdDocumentId(PAGE_PATH))}`);
|
||||||
|
url.searchParams.set("sourceKind", "local_folder");
|
||||||
|
url.searchParams.set("rootUri", ROOT_URI);
|
||||||
|
url.searchParams.set("workspaceId", WORKSPACE_ID);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillConfig(name, description, source, riskLevel, requiredScopes = []) {
|
||||||
|
return { name, description, source, riskLevel, requiredScopes, enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mcpConfig(name, description, transport, command, url, networkPolicy, secretRefs, riskLevel, requiredScopes = []) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
transport,
|
||||||
|
command,
|
||||||
|
url,
|
||||||
|
networkPolicy,
|
||||||
|
secretRefs,
|
||||||
|
riskLevel,
|
||||||
|
requiredScopes,
|
||||||
|
enabled: true,
|
||||||
|
facadeOnly: true,
|
||||||
|
sandbox: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
|
||||||
|
return { name, description, source, toolNames, riskLevel, requiredScopes, enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyForExactPrompt() {
|
||||||
|
return {
|
||||||
|
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
|
||||||
|
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
|
||||||
|
tools: {
|
||||||
|
"mnote.current_page.read": "allow",
|
||||||
|
"mnote.selection.read": "allow",
|
||||||
|
"mnote.allowed_roots.describe": "allow",
|
||||||
|
"mnote.local_file.read": "allow",
|
||||||
|
"mnote.local_file.patch": "ask",
|
||||||
|
"mnote.knowledge_rag.status": "allow",
|
||||||
|
"mnote.knowledge_rag.query": "allow",
|
||||||
|
"mnote.knowledge_rag.section_context": "allow",
|
||||||
|
"mnote.knowledge_rag.open_reference": "allow",
|
||||||
|
"mnote.reference.open": "allow",
|
||||||
|
"mnote.tool_receipt.write": "allow",
|
||||||
|
"mnote.codex_rescue.request": "ask",
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
codegraph: skillConfig("CodeGraph", "读取项目代码图、符号和调用关系。", "mcp://codegraph", "medium", ["workspace:code-read"]),
|
||||||
|
searxng: skillConfig("SearXNG Search", "通过本地 SearXNG MCP 做网页检索。", "mcp://searxng", "medium", ["network:search"]),
|
||||||
|
"chrome-bridge": skillConfig("Chrome Bridge", "通过受控浏览器桥接执行页面验证。", "mcp://chrome-bridge", "high", ["browser:automation", "qa:browser"]),
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
codegraph: mcpConfig("CodeGraph", "代码图 MCP。", "stdio", "codegraph serve --mcp", "", "deny-all", [], "medium", ["workspace:code-read"]),
|
||||||
|
searxng: mcpConfig("SearXNG", "本地 SearXNG 检索 MCP。", "stdio", "node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs", "", "allow-local", [], "medium", ["network:search"]),
|
||||||
|
"chrome-bridge": mcpConfig("Chrome Bridge", "本机 Chromium/Chrome 桥接。", "stdio", "node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs", "", "allow-local", [], "high", ["browser:automation", "qa:browser"]),
|
||||||
|
},
|
||||||
|
piExtensions: {
|
||||||
|
"pi-rust-official-todo": piExtensionConfig("Pi Rust Official Todo", "Pi Rust 官方 todo 扩展。", "pi-rust-official:todo", ["todo"], "medium", ["workflow:todo"]),
|
||||||
|
"pi-rust-official-subagent": piExtensionConfig("Pi Rust Official Subagent", "Pi Rust 官方 subagent 扩展。", "pi-rust-official:subagent", ["subagent"], "high", ["agent:delegate"]),
|
||||||
|
"pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方 permission-gate 扩展。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||||
|
quickLoginButton.click(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson(page, url, options = {}) {
|
||||||
|
const response = await page.request.fetch(`${BASE}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
timeout: options.timeout || TIMEOUT,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
body = { raw: text };
|
||||||
|
}
|
||||||
|
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 1000)}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedWorkspace(page) {
|
||||||
|
mkdirp(ROOT_PATH);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ROOT_PATH, PAGE_PATH),
|
||||||
|
[
|
||||||
|
"# Pi exact user web smoke",
|
||||||
|
"",
|
||||||
|
"PI_EXACT_USER_WEB_CONTEXT_OK",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await setupWorkspaceAccess(page.request, BASE, {
|
||||||
|
actorId: ACTOR_ID,
|
||||||
|
email: "mnote.e2e@example.com",
|
||||||
|
username: ACTOR_ID,
|
||||||
|
displayName: ACTOR_ID,
|
||||||
|
role: "admin",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceName: "Pi exact user web smoke",
|
||||||
|
rootPath: ROOT_PATH,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
permission: "write",
|
||||||
|
capabilities: ["ai", "read", "write"],
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
await seedAiPolicy(page.request, BASE, {
|
||||||
|
id: `pi-user-exact-${ACTOR_ID}-${WORKSPACE_ID}`,
|
||||||
|
userId: ACTOR_ID,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
|
||||||
|
modelPolicyJson: policyForExactPrompt(),
|
||||||
|
quotaJson: { daily: 200 },
|
||||||
|
timeoutMs: TIMEOUT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abortExistingSession(page) {
|
||||||
|
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
|
||||||
|
const sessionId = status?.session?.sessionId;
|
||||||
|
if (!sessionId) return null;
|
||||||
|
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId },
|
||||||
|
}).catch(() => null);
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventPayload(event) {
|
||||||
|
return event && event.payload ? event.payload : event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeEvents(events) {
|
||||||
|
const summary = {
|
||||||
|
eventCount: events.length,
|
||||||
|
toolCalls: [],
|
||||||
|
toolStarts: [],
|
||||||
|
toolEnds: [],
|
||||||
|
assistantTexts: [],
|
||||||
|
runtimeClosed: [],
|
||||||
|
agentEnd: false,
|
||||||
|
};
|
||||||
|
for (const event of events) {
|
||||||
|
const payload = eventPayload(event) || {};
|
||||||
|
const type = payload.type || event.kind;
|
||||||
|
if (type === "runtime_stdout_closed") summary.runtimeClosed.push(payload);
|
||||||
|
if (type === "agent_end") summary.agentEnd = true;
|
||||||
|
if (type === "message" || type === "message_end") {
|
||||||
|
const message = payload.message || {};
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const text = (message.content || [])
|
||||||
|
.filter((part) => part && part.type === "text" && typeof part.text === "string")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("");
|
||||||
|
if (text.trim()) summary.assistantTexts.push(text.trim());
|
||||||
|
for (const part of message.content || []) {
|
||||||
|
if (part && part.type === "toolCall") {
|
||||||
|
summary.toolCalls.push({ name: part.name || part.toolName, id: part.id || part.toolCallId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (message.role === "toolResult") {
|
||||||
|
summary.toolEnds.push({
|
||||||
|
name: message.toolName || message.tool_name,
|
||||||
|
id: message.toolCallId || message.tool_call_id,
|
||||||
|
isError: message.isError === true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === "tool_execution_start" || type === "tool_call_start") {
|
||||||
|
summary.toolStarts.push({ name: payload.toolName || payload.name, id: payload.toolCallId || payload.id });
|
||||||
|
}
|
||||||
|
if (type === "tool_execution_end" || type === "tool_call_end") {
|
||||||
|
summary.toolEnds.push({ name: payload.toolName || payload.name, id: payload.toolCallId || payload.id, isError: payload.isError === true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visibleAssistantTexts(page) {
|
||||||
|
const texts = await page
|
||||||
|
.locator('[data-page-ai-pi-lab-message-role="assistant"]:not([data-page-ai-pi-lab-streaming])')
|
||||||
|
.allTextContents()
|
||||||
|
.catch(() => []);
|
||||||
|
return texts.map((text) => String(text || "").trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visibleToolCards(page) {
|
||||||
|
return await page
|
||||||
|
.locator("[data-page-ai-pi-lab-tool-card]")
|
||||||
|
.evaluateAll((cards) => cards.map((card) => {
|
||||||
|
const name = card.getAttribute("data-page-ai-pi-lab-tool-call")
|
||||||
|
|| card.getAttribute("data-page-ai-pi-lab-tool-card")
|
||||||
|
|| "";
|
||||||
|
const status = card.querySelector("[data-page-ai-pi-lab-tool-status]")?.textContent || "";
|
||||||
|
return {
|
||||||
|
name: String(name || "").trim(),
|
||||||
|
status: String(status || "").trim(),
|
||||||
|
text: String(card.textContent || "").slice(0, 500),
|
||||||
|
};
|
||||||
|
}).filter((tool) => tool.name))
|
||||||
|
.catch(() => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeVisibleToolCards(summary, tools) {
|
||||||
|
for (const tool of tools || []) {
|
||||||
|
if (!summary.toolCalls.some((existing) => existing.name === tool.name)) {
|
||||||
|
summary.toolCalls.push({ name: tool.name, id: tool.name, source: "visible_dom" });
|
||||||
|
}
|
||||||
|
const statusText = `${tool.status || ""} ${tool.text || ""}`;
|
||||||
|
if (/done|完成/i.test(statusText) && !summary.toolEnds.some((existing) => existing.name === tool.name)) {
|
||||||
|
summary.toolEnds.push({ name: tool.name, id: tool.name, isError: /error|失败/i.test(statusText), source: "visible_dom" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSessionEvents(page, sessionId) {
|
||||||
|
const deadline = Date.now() + TIMEOUT;
|
||||||
|
let events = [];
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const visibleTexts = await visibleAssistantTexts(page);
|
||||||
|
const visibleFinalText = visibleTexts.find((text) => (
|
||||||
|
text.length > 40
|
||||||
|
&& /MCP|扩展|skill|工具|测试|Chrome Bridge|总体结论/i.test(text)
|
||||||
|
&& !/Pi runtime 返回了空回复|empty response/i.test(text)
|
||||||
|
));
|
||||||
|
if (visibleFinalText) {
|
||||||
|
const summary = summarizeEvents(events);
|
||||||
|
mergeVisibleToolCards(summary, await visibleToolCards(page));
|
||||||
|
summary.assistantTexts.push(visibleFinalText);
|
||||||
|
return { events, summary };
|
||||||
|
}
|
||||||
|
const payload = await requestJson(
|
||||||
|
page,
|
||||||
|
`/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}/events?limit=1000`,
|
||||||
|
{ timeout: Math.min(15000, TIMEOUT) },
|
||||||
|
).catch(() => null);
|
||||||
|
events = payload && Array.isArray(payload.events) ? payload.events : events;
|
||||||
|
const summary = summarizeEvents(events);
|
||||||
|
mergeVisibleToolCards(summary, await visibleToolCards(page));
|
||||||
|
if (summary.agentEnd || summary.assistantTexts.length > 0) return { events, summary };
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
const summary = summarizeEvents(events);
|
||||||
|
mergeVisibleToolCards(summary, await visibleToolCards(page));
|
||||||
|
return { events, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBadToolBridgeResult(text) {
|
||||||
|
return /page_ai_pi_lab_session_not_found|page_ai_pi_lab_bridge_token_invalid|mnote_pi_rust_service_bridge_unavailable|mnote_pi_bridge_session_id_missing/i.test(String(text || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirp(OUT);
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.MNOTE_PI_USER_EXACT_WEB_HEADED === "1" ? false : true,
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__MNOTE_PI_LAB_TEST__ = true;
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const consoleMessages = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
|
||||||
|
});
|
||||||
|
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
ok: false,
|
||||||
|
base: BASE,
|
||||||
|
outputDir: OUT,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
pagePath: PAGE_PATH,
|
||||||
|
prompt: USER_PROMPT,
|
||||||
|
screenshots: {},
|
||||||
|
checks: {},
|
||||||
|
consoleMessages,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
await seedWorkspace(page);
|
||||||
|
await abortExistingSession(page);
|
||||||
|
|
||||||
|
let startCount = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/page-ai/pi/start") && request.method() === "POST") startCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(documentUrl(), { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.locator("[data-page-ai-pi-lab-launcher]").click();
|
||||||
|
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
result.checks.startCountAfterOpen = startCount;
|
||||||
|
assert.equal(result.checks.startCountAfterOpen, 0, "opening Pi Lab must not auto-start runtime");
|
||||||
|
|
||||||
|
await page.locator("[data-page-ai-pi-lab-new]").click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
result.checks.startCountAfterNew = startCount;
|
||||||
|
assert.equal(result.checks.startCountAfterNew, 0, "new conversation must not auto-start runtime");
|
||||||
|
|
||||||
|
await page.locator("[data-page-ai-pi-lab-permission]").click();
|
||||||
|
await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click();
|
||||||
|
await page.waitForFunction(() => /完全访问/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
result.checks.startCountAfterFullAccessClick = startCount;
|
||||||
|
assert.equal(result.checks.startCountAfterFullAccessClick, 0, "selecting full_access before send must not auto-start runtime");
|
||||||
|
|
||||||
|
const startResponsePromise = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("/api/page-ai/pi/start") && response.request().method() === "POST",
|
||||||
|
{ timeout: TIMEOUT },
|
||||||
|
);
|
||||||
|
const sendResponsePromise = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("/api/page-ai/pi/send") && response.request().method() === "POST",
|
||||||
|
{ timeout: TIMEOUT },
|
||||||
|
);
|
||||||
|
await page.locator("[data-page-ai-pi-lab-input]").fill(USER_PROMPT);
|
||||||
|
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||||
|
|
||||||
|
const startResponse = await startResponsePromise;
|
||||||
|
const startBody = await startResponse.json();
|
||||||
|
const startRequestBody = startResponse.request().postDataJSON();
|
||||||
|
result.startRequest = startRequestBody;
|
||||||
|
result.startResponse = startBody;
|
||||||
|
assert.equal(startResponse.ok(), true, `start response should be HTTP OK: ${startResponse.status()}`);
|
||||||
|
assert.equal(startRequestBody.permissionMode, "full_access", "web send must start with full_access");
|
||||||
|
assert.equal(startRequestBody.modelProvider, MODEL_PROVIDER, "web send must use configured provider");
|
||||||
|
assert.equal(startRequestBody.modelId, MODEL_ID, "web send must use tool-capable model");
|
||||||
|
assert.equal(startRequestBody.rootUri, ROOT_URI, "web send must keep current document rootUri");
|
||||||
|
assert.equal(startRequestBody.workspaceId, WORKSPACE_ID, "web send must keep current document workspaceId");
|
||||||
|
assert.equal(startRequestBody.pagePath, PAGE_PATH, "web send must keep current document pagePath");
|
||||||
|
assert.equal(startBody.runtimeImplementation, "pi-rust", "runtime implementation should be pi-rust");
|
||||||
|
assert.deepEqual(
|
||||||
|
[...(startBody.managedPiBuiltinTools || [])].sort(),
|
||||||
|
["bash", "edit", "find", "grep", "hashline_edit", "ls", "read", "write"].sort(),
|
||||||
|
"full_access should expose Pi Rust builtins instead of replacing them with MNote file tools",
|
||||||
|
);
|
||||||
|
const sessionId = startBody.session?.sessionId || startBody.sessionId;
|
||||||
|
assert(sessionId, "start response missing sessionId");
|
||||||
|
result.sessionId = sessionId;
|
||||||
|
result.piSessionDir = startBody.session?.piSessionDir;
|
||||||
|
|
||||||
|
const sendResponse = await sendResponsePromise;
|
||||||
|
const sendBody = await sendResponse.json();
|
||||||
|
result.sendResponse = sendBody;
|
||||||
|
assert.equal(sendResponse.ok(), true, `send response should be HTTP OK: ${sendResponse.status()}`);
|
||||||
|
assert.equal(sendBody.accepted, true, "send response should accept the exact prompt");
|
||||||
|
|
||||||
|
const { events, summary } = await waitForSessionEvents(page, sessionId);
|
||||||
|
result.events = events;
|
||||||
|
result.eventSummary = summary;
|
||||||
|
result.visibleToolCards = await visibleToolCards(page);
|
||||||
|
mergeVisibleToolCards(summary, result.visibleToolCards);
|
||||||
|
const pageText = await page.locator('[data-page-ai-pi-lab="drawer"]').textContent({ timeout: TIMEOUT });
|
||||||
|
result.visibleTextSample = String(pageText || "").slice(0, 4000);
|
||||||
|
await page.screenshot({ path: path.join(OUT, "01-exact-prompt-result.png"), fullPage: true });
|
||||||
|
result.screenshots.exactPrompt = path.join(OUT, "01-exact-prompt-result.png");
|
||||||
|
|
||||||
|
const allText = `${JSON.stringify(summary)}\n${result.visibleTextSample}\n${JSON.stringify(events)}`;
|
||||||
|
result.checks.hasToolCall = summary.toolCalls.length > 0 || summary.toolStarts.length > 0 || summary.toolEnds.length > 0;
|
||||||
|
result.checks.hasSuccessfulToolEnd = summary.toolEnds.some((tool) => tool.isError === false);
|
||||||
|
result.checks.hasMnoteOrMcpOrExtensionTool = /mnote_|mcp|todo|subagent|knowledge/i.test(JSON.stringify(summary));
|
||||||
|
result.checks.noRawBuiltinToolCall = !/"name":"(ls|find|bash|read|write|edit|grep|hashline_edit)"/.test(JSON.stringify(events));
|
||||||
|
result.checks.noEmptyReplyError = !/Pi runtime 返回了空回复|empty response/i.test(allText);
|
||||||
|
result.checks.noBridgeSessionFailure = !hasBadToolBridgeResult(allText);
|
||||||
|
result.checks.hasAssistantReply = summary.assistantTexts.length > 0 || /Pi|测试|工具|MCP|扩展|skill/i.test(result.visibleTextSample);
|
||||||
|
assert.equal(result.checks.hasToolCall, true, "exact web prompt should trigger at least one real tool call");
|
||||||
|
assert.equal(result.checks.hasSuccessfulToolEnd, true, "at least one tool call should finish successfully");
|
||||||
|
assert.equal(result.checks.hasMnoteOrMcpOrExtensionTool, true, "tool call should be MNote/MCP/extension controlled");
|
||||||
|
assert.equal(result.checks.noRawBuiltinToolCall, true, "full_access must not expose raw builtin tools");
|
||||||
|
assert.equal(result.checks.noEmptyReplyError, true, "UI must not show empty reply error");
|
||||||
|
assert.equal(result.checks.noBridgeSessionFailure, true, "tool result must not contain bridge/session failures");
|
||||||
|
assert.equal(result.checks.hasAssistantReply, true, "UI should show an assistant reply");
|
||||||
|
|
||||||
|
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => ({}));
|
||||||
|
result.ok = true;
|
||||||
|
} catch (error) {
|
||||||
|
result.ok = false;
|
||||||
|
result.error = error && error.stack ? error.stack : String(error);
|
||||||
|
await abortExistingSession(page).catch(() => null);
|
||||||
|
try {
|
||||||
|
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
|
||||||
|
result.screenshots.failure = path.join(OUT, "99-failure.png");
|
||||||
|
} catch {}
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||||
|
await browser.close();
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: result.ok,
|
||||||
|
outputDir: OUT,
|
||||||
|
result: path.join(OUT, "result.json"),
|
||||||
|
sessionId: result.sessionId,
|
||||||
|
checks: result.checks,
|
||||||
|
error: result.error,
|
||||||
|
}, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
|
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "120000", 10);
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const OUT = process.env.MNOTE_PI_WARMUP_BINDING_OUT || path.join(os.tmpdir(), `mnote-pi-warmup-binding-${STAMP}`);
|
||||||
|
const ROOT_PATH = process.env.MNOTE_PI_WARMUP_BINDING_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
|
||||||
|
const ROOT_URI = process.env.MNOTE_PI_WARMUP_BINDING_ROOT_URI || `file://${ROOT_PATH}`;
|
||||||
|
const WORKSPACE_ID = process.env.MNOTE_PI_WARMUP_BINDING_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
|
||||||
|
const PAGE_PATH = `pi-warmup-binding-${STAMP}.md`;
|
||||||
|
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||||
|
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
|
||||||
|
|
||||||
|
async function requestJson(page, url, options = {}) {
|
||||||
|
const response = await page.request.fetch(`${BASE}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
timeout: options.timeout || TIMEOUT,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
body = { raw: text };
|
||||||
|
}
|
||||||
|
return { response, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
|
||||||
|
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
if (!(await quickLoginButton.isVisible({ timeout: 4000 }).catch(() => false))) return;
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
|
||||||
|
quickLoginButton.click(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupPreviousSmokeSession(page) {
|
||||||
|
const status = await requestJson(page, "/api/page-ai/pi/status");
|
||||||
|
assert(status.response.ok(), `status before cleanup should be OK, got ${status.response.status()}`);
|
||||||
|
const session = status.body.session || {};
|
||||||
|
const sessionId = status.body.sessionId || session.sessionId;
|
||||||
|
const pagePath = String(session.pagePath || "");
|
||||||
|
if (!sessionId || !pagePath.startsWith("pi-warmup-binding-")) return;
|
||||||
|
await requestJson(page, "/api/page-ai/pi/abort", {
|
||||||
|
method: "POST",
|
||||||
|
data: { sessionId },
|
||||||
|
}).catch(() => null);
|
||||||
|
await requestJson(page, `/api/page-ai/pi/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
fs.mkdirSync(ROOT_PATH, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi warmup binding\n\nWarmup binding smoke.\n", "utf8");
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: process.env.MNOTE_PI_WARMUP_BINDING_HEADED === "1" ? false : true,
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE || undefined,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
await context.addInitScript((payload) => {
|
||||||
|
window.__MNOTE_TEST_PAGE_CONTEXT__ = payload;
|
||||||
|
}, {
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
pagePath: PAGE_PATH,
|
||||||
|
pageTitle: "Pi warmup binding",
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const requests = [];
|
||||||
|
const result = {
|
||||||
|
base: BASE,
|
||||||
|
out: OUT,
|
||||||
|
rootUri: ROOT_URI,
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
pagePath: PAGE_PATH,
|
||||||
|
statusBefore: null,
|
||||||
|
statusAfter: null,
|
||||||
|
startRequest: null,
|
||||||
|
startBeforeSend: false,
|
||||||
|
coldStartToastVisible: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
await cleanupPreviousSmokeSession(page);
|
||||||
|
const statusBefore = await page.request.get(`${BASE}/api/page-ai/pi/status`);
|
||||||
|
assert(statusBefore.ok(), `status before should be OK, got ${statusBefore.status()}`);
|
||||||
|
result.statusBefore = await statusBefore.json();
|
||||||
|
assert.equal(result.statusBefore.warmupRunning, true, "dev:hot warmup runtime should be running before UI send");
|
||||||
|
assert.equal(result.statusBefore.sessionId || null, null, "warmup session must not be exposed as current page session");
|
||||||
|
|
||||||
|
await page.route("**/api/page-ai/pi/send", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
requests.push({ url: request.url(), body: request.postDataJSON() });
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
schema: "mnote.page_ai_pi.send.v1",
|
||||||
|
sessionId: request.postDataJSON().sessionId,
|
||||||
|
accepted: true,
|
||||||
|
intercepted: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = `local-md:${PAGE_PATH.replaceAll("/", "~2F")}`;
|
||||||
|
await page.goto(
|
||||||
|
`${BASE}/documents/${encodeURIComponent(documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(ROOT_URI)}&workspaceId=${encodeURIComponent(WORKSPACE_ID)}&path=${encodeURIComponent(PAGE_PATH)}`,
|
||||||
|
{ waitUntil: "commit", timeout: TIMEOUT },
|
||||||
|
);
|
||||||
|
await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
const startResponsePromise = page.waitForResponse(
|
||||||
|
(response) => response.url().includes("/api/page-ai/pi/start") && response.request().method() === "POST",
|
||||||
|
{ timeout: TIMEOUT },
|
||||||
|
);
|
||||||
|
await page.locator("[data-page-ai-pi-lab-launcher]").click();
|
||||||
|
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
|
||||||
|
const startResponse = await startResponsePromise;
|
||||||
|
result.startBeforeSend = true;
|
||||||
|
assert(startResponse.ok(), `start response should be OK, got ${startResponse.status()}`);
|
||||||
|
const startBody = await startResponse.json();
|
||||||
|
result.startRequest = startResponse.request().postDataJSON();
|
||||||
|
assert.equal(startBody.ok, true, "start body should be ok");
|
||||||
|
assert(startBody.session?.sessionId, "start should return a current page session");
|
||||||
|
assert(!String(startBody.session.sessionId).startsWith("pi_lab_dev_warm_"), "current page session should not reuse warmup prompt session id");
|
||||||
|
assert.equal(startBody.session.pagePath, PAGE_PATH, "current page session should bind current page path");
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready")
|
||||||
|
|| document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("streaming"),
|
||||||
|
null,
|
||||||
|
{ timeout: TIMEOUT },
|
||||||
|
);
|
||||||
|
await page.locator("[data-page-ai-pi-lab-input]").fill("warmup binding smoke");
|
||||||
|
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
|
||||||
|
result.coldStartToastVisible = await page.locator("[data-page-ai-pi-lab-toast]", { hasText: "正在绑定当前页 Pi 会话,完成后自动发送" }).isVisible({ timeout: 500 }).catch(() => false);
|
||||||
|
assert.equal(result.coldStartToastVisible, false, "send should not show current-page binding toast after drawer-open prestart");
|
||||||
|
const sendWaitStartedAt = Date.now();
|
||||||
|
while (requests.length === 0 && Date.now() - sendWaitStartedAt < 5000) {
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
assert.equal(requests.length, 1, "send should be intercepted once after runtime binding");
|
||||||
|
assert.equal(requests[0].body.sessionId, startBody.session.sessionId, "send should use bound current page session");
|
||||||
|
|
||||||
|
const statusAfter = await page.request.get(`${BASE}/api/page-ai/pi/status`);
|
||||||
|
assert(statusAfter.ok(), `status after should be OK, got ${statusAfter.status()}`);
|
||||||
|
result.statusAfter = await statusAfter.json();
|
||||||
|
assert.equal(result.statusAfter.warmupRunning, true, "warmup diagnostics should remain visible after current session starts");
|
||||||
|
assert.equal(result.statusAfter.sessionId, startBody.session.sessionId, "status should now expose the current page session");
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
|
||||||
|
console.log(`✅ Pi warmup binding browser smoke passed: ${path.join(OUT, "result.json")}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user