feat: add pi lab ai management integration

This commit is contained in:
Agent Board
2026-07-04 21:47:42 +08:00
parent 36d027a4a1
commit c8472bb898
62 changed files with 15077 additions and 443 deletions
+6 -5
View File
@@ -7,11 +7,12 @@ dist/
build/ build/
**/build/ **/build/
.turbo/ .turbo/
pnpm-debug.log* pnpm-debug.log*
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
*.log *.tgz
*.log
.DS_Store .DS_Store
.env .env
.env.* .env.*
+4
View File
@@ -129,6 +129,10 @@
- 若当前问题只是 UI 表现异常,先确认是否是实验性 tree shell、compat 路径、轮询或 fallback 混入首屏主链,而不是直接怀疑 Convex 本身。 - 若当前问题只是 UI 表现异常,先确认是否是实验性 tree shell、compat 路径、轮询或 fallback 混入首屏主链,而不是直接怀疑 Convex 本身。
- 若发现现有轮询或定时刷新链路,不要直接在其上继续叠加补丁;先确认是否能改成事件驱动、watcher/realtime 推送或命令结果定点刷新,再决定是否保留临时 fallback。 - 若发现现有轮询或定时刷新链路,不要直接在其上继续叠加补丁;先确认是否能改成事件驱动、watcher/realtime 推送或命令结果定点刷新,再决定是否保留临时 fallback。
- 子 agent 只能作为受控叶子 worker:任务书必须限定读写范围、验证命令、交付文件和禁止项;不得自行再启动 runner、subagent、额外 worktree 或修改任务书。Codex 主控必须复核 diff、handoff/result、验证证据和 git 状态后才能采纳;Reasonix/Hermes 只属于历史 provider 边界。 - 子 agent 只能作为受控叶子 worker:任务书必须限定读写范围、验证命令、交付文件和禁止项;不得自行再启动 runner、subagent、额外 worktree 或修改任务书。Codex 主控必须复核 diff、handoff/result、验证证据和 git 状态后才能采纳;Reasonix/Hermes 只属于历史 provider 边界。
- 主控必须维护当前 subagent 的任务书、写入范围和采纳状态。任何 `worker` 启动后到完成/取消前,都视为可能写入;主控不得同时手工修改同一文件范围,也不得再派第二个 worker 修改同一范围。
- 主控不得因为自己已经手工完成同一范围就提前关闭仍在运行的实现型 subagent;若该 worker 的写入范围已失效,必须先 interrupt/send_input 明确取消并说明其结果不再采纳,随后等待 shutdown/完成通知。只有已完成、已取消且确认不会再写入、或用户明确要求停止时,才 close_agent。
- 使用 subagent 的目标是缩短关键路径。委派前必须确认子任务不会阻塞主控下一步、不会与主控或其它 worker 写同一文件、且主控有明确的结果采纳点;否则主控直接做。若 subagent 结果晚到,主控必须标记“未采纳”或复核后合并,不能让后台结果与主线程补丁并存成冲突。
- 进入最终测试或最终回复前,主控必须确认本轮仍在运行的实现型 subagent 已完成、已明确取消并停止,或其输出已被标记为不采纳;不得在后台 worker 仍可能写入时声称已完成。
## CodeGraph 使用 ## CodeGraph 使用
@@ -0,0 +1,90 @@
# 7-69 Page AI Pi-first Lab Spike Checklist v1
状态:done(最小可验证 spike)
Owner07-ai / mnote-web / control-plane
日期:2026-07-04
## 1. 结论
7-69 的最小可验证 spike 已完成:OpenHub 继续作为默认 Page AI 主线,Pi Lab 仅在 `MNOTE_PAGE_AI_PI_LAB=1` 下启用,运行时默认禁用 Pi 原始 `bash/read/write/edit`,文件读写只走 MNote-owned tool facade 与 allowed roots。
Pi 可以进入下一阶段受控验证,但不能进入生产默认路径。下一阶段的门槛是 Turso/libSQL 正式 receipt 表、登录态 watcher -> tiptap 端到端验证、selection snapshot 注入和 UI 产品化。
## 2. Phase Checklist
### Phase A:源码与包可运行性
- [x] 重新拉取并核验当前 Pi 源码:`tmp/pi-research-20260703/pi-mono`commit `23d14626 fix(ai): rotate stale Codex websocket sessions`
- [x] 核验当前 npm 包:`@earendil-works/pi-coding-agent@0.80.3`,并通过 `MNOTE_PAGE_AI_PI_BIN` wrapper 跑通真实 RPC。
- [x] 跑通 `pi --mode rpc` 最小托管路径。
- [x] 记录 RPC event schema 到 MNote SSE `pi_rpc_event` 映射。
- [x] 验证 `abort`MNote 可向 Pi RPC 发送 abort 并清理子进程。
- [ ] `steer` / `follow_up` 行为未纳入本轮 spike,留到下一阶段真实 agent turn 验收。
### Phase BMNote scoped Pi runtime
- [x] 新增内部 runtime manager,不替换 OpenHub 默认入口。
- [x] `sessionDir` 派生到 MNote 管控目录:`<workspace>/.mnote/ai/pi-sessions/<actor>/<session>`
- [x] 禁止全局 `~/.pi/agent/sessions` 作为默认 MNote session 落点。
- [x] 只开放 MNote-owned tools,默认不开放 Pi builtin `bash/read/write/edit`
- [x] 路径越界读写被拒绝,并写入 provider-neutral receipt。
### Phase CLightRAG tool
- [x] 实现 `mnote.knowledge_rag.query` facade。
- [x] 通过现有 `knowledge_rag::query_rag` 调用 LightRAG provider facade,不引入第二套 RAG。
- [x] tool envelope 保留 citation/open-reference 所需字段。
- [x] UI 支持 citation 展示。
- [x] 实现 `mnote.reference.open` facade,支持 citation 回跳入口。
### Phase D:文件 patch 闭环
- [x] 当前页面可通过 `rootUri + pagePath` 解析到真实 `.md`
- [x] `mnote.local_file.patch` 支持 content 或 text operations patch 当前文件。
- [x] patch 写入前后记录 file version 与 diff summary。
- [x] patch 响应声明 watcher/document-session refresh,并显式 `polling=false`
- [ ] 登录态浏览器中验证 watcher 实际刷新 tiptap 当前页仍未完成,是下一阶段阻塞项。
- [ ] changed file chip 打开对应资源未纳入本轮 spike,是下一阶段 UI 验收项。
### Phase EUI 对照
- [x] 新增 MNote-native Pi Lab internal UI,支持 start/send/events/abort,并集成到当前 MNote Page AI 抽屉。
- [x] Pi RPC stream 的 `thinking_*` 事件不进入可见 assistant bubble;截图验证 Omniroute/freefirst 最终可见回复为 `OK`
- [x] UI 支持 SSE stream、tool card、citation、diff summary、receipt 诊断。
- [x] 重新拉取并评估 `@earendil-works/pi-web-ui@0.75.3`;确认其 UI 成熟,但默认 browser Agent/IndexedDB/API key/tools/artifacts 边界不适合直接承接 MNote 托管 Pi RPC/SSE。
- [x] 采用参考官方 `ChatPanel/AgentInterface` 形态的 MNote-native adapter,并保留后续 remote-session adapter 接官方组件的迁移条件。
- [ ] 与 OpenHub 当前面板并排截图对照未纳入本轮 spike。
- [ ] 移动端 / 窄 sidebar 布局未纳入本轮 spike。
- [ ] retry / session resume 仍是下一阶段 UI 产品化项。
## 3. 验收结果
- [x] `pi --mode rpc` 能由 MNote 托管启动,并返回 `runtimePid`
- [x] 当前页读取、LightRAG facade、文件 patch、receipt adapter、SSE UI 已形成最小闭环。
- [x] 所有文件读写受 allowed roots 控制。
- [x] 所有 tool call 均写 provider-neutral JSONL receipt。
- [x] Pi 原始 `bash/read/write/edit` 默认不可用。
- [x] citation/open-reference facade 已接入 MNote knowledge_rag 路径。
- [x] Pi Lab 与 OpenHub 并存,OpenHub 仍为默认 Page AI。
- [ ] Turso/libSQL 正式 receipt 表未完成。
- [ ] watcher -> tiptap 真实端到端刷新未完成。
- [ ] UI 尚未达到可替代 OpenHub 的生产级体验。
## 4. 验证命令
- `node scripts/task-pi-lab-static-smoke.js`:通过,78 checks。
- `node scripts/task-pi-lab-api-endpoint-smoke.js`:通过,10/10。
- `node scripts/task-pi-lab-mock-api-smoke.js`:通过。
- `node scripts/task-pi-lab-browser-smoke.js`:通过。
- `node scripts/task-pi-lab-rpc-api-smoke.js`:新增固化 RPC smoke;需以 `MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc``MNOTE_PAGE_AI_PI_BIN` 启动服务后运行。
- `cd rust && cargo check -p mnote-web`:通过。
## 5. 下一阶段条件
进入下一阶段前必须补齐:
- Turso/libSQL control-plane 表:`ai_provider_sessions``ai_tool_events``ai_file_patches`
- 登录态浏览器 smokePi patch 当前 `.md` 后,watcher 驱动 tiptap 当前页刷新,不允许高频轮询。
- sidebar host 注入真实 selection snapshot。
- Pi RPC send 的真实 agent turn 自动化验证,固定兼容版本范围或安装方式。
- UI 与 OpenHub baseline 的截图/交互对照。
@@ -0,0 +1,94 @@
# 7-69 Page AI Pi-first Lab Spike 验收记录 v1
状态:done(最小可验证 spike)
Owner07-ai / mnote-web / control-plane
日期:2026-07-03
## 1. 本轮结论
7-69 已完成 MNote 侧最小可验证 Pi-first Page AI Lab spikeOpenHub 仍是默认 Page AI 主线,Pi Lab 仅在 `MNOTE_PAGE_AI_PI_LAB=1` 下作为 dev-only/internal 入口启用。
当前结论不是“替换 OpenHub”,而是:Pi 值得进入下一阶段受控验证;扩大前必须补真实 Pi CLI/RPC、Turso/libSQL receipt 表、auth 下浏览器 smoke 和 watcher 端到端证据。
## 2. 已实现内容
- 新增 Pi Lab 后端路由:`/api/page-ai/pi/status``bootstrap``start``send``abort``events``tool-call``/page-ai/pi`
- 新增 MNote 托管 runtime`pi --mode rpc` subprocess 或 `MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock`sessionDir 派生到 MNote 管控目录。
- 默认禁用 Pi builtin tools:启动参数包含 `--no-builtin-tools`,状态响应显式标注 `bash/read/write/edit` 不开放。
- 实现 MNote-owned tool facade`mnote.current_page.read``mnote.selection.read``mnote.allowed_roots.describe``mnote.local_file.read``mnote.local_file.patch``mnote.knowledge_rag.query``mnote.reference.open``mnote.tool_receipt.write`
- 文件读写受 allowed roots 限制,越界读写返回 deny reason 且写 receipt。
- LightRAG 保持唯一默认知识 provider;Pi 只通过 `knowledge_rag` facade 查询和 open-reference。
- receipt 先落 `provider_neutral_jsonl_adapter_v1`,字段含 session binding、tool event、deny reason、diff summary、file version、allowed roots snapshot,并写明迁移到 Turso/libSQL `ai_provider_sessions` / `ai_tool_events` / `ai_file_patches` 的条件。
- 文件 patch 响应声明 `mnote local-folder watcher / document-session external refresh`,并显式 `polling=false`,不新增高频轮询。
- 新增 Pi Lab runtime UI:状态机、SSE/EventSource、stream、tool card、citation、diff、abort、receipt 诊断。
- Pi Lab 已从简陋独立 debug 页改为当前 MNote Page AI 抽屉内的 dev-only provider 面板,并保留悬浮 `π` 入口;切回 OpenHub 时复用原抽屉与 iframeOpenHub 仍是默认入口。
- Pi RPC stream 中 `thinking_*` 事件默认不进入可见 assistant bubble;可见回复只渲染 `text_*` / assistant final text,避免 Omniroute/freefirst 的 reasoning_content 泄漏到页面。
- gateway 仅在 `enable_page_ai_pi_lab` 开启时注入 Pi Lab runtime,默认 body 标记为 hidden,不改变 OpenHub 默认入口。
- 新增 smoke:静态结构、禁用态 API endpoint、mock-mode API 验证脚本、浏览器 smoke 脚本。
- 新增 spike checklist`design/07-ai/done/7-69-page-ai-pi-first-lab-checklist-v1.md`,逐项标注 Phase A-E 已完成项与下一阶段 gap。
## 3. 未完成内容
- 系统 PATH 仍未安装全局 `pi`,但已通过 `npx -y @earendil-works/pi-coding-agent@0.80.3` 包装器完成真实 `pi --mode rpc` 端到端 smoke;后续需决定是否固定安装方式或配置 `MNOTE_PAGE_AI_PI_BIN`
- receipt 尚未写入 Turso/libSQL 正式表,仍为 provider-neutral JSONL adapter。
- `mnote.tool_receipt.write` 当前仍是最小 tool envelope 路径,未作为独立持久化入口深化。
- watcher 刷新已按 MNote 文件写入链路声明和返回 metadata,但还需登录态浏览器端 smoke 证明 tiptap 当前页被 watcher 刷新。
- 已基于最新 `@earendil-works/pi-web-ui@0.75.3` 源码重新评估,而不是使用 recycle 旧源码。其 `ChatPanel` / `AgentInterface` / `MessageEditor` / message-tool renderer 是成熟 UI 参考,但直接接入会同时引入浏览器端 `pi-agent-core`、IndexedDB session/provider key、API key prompt、proxy、attachments、artifacts 和 `Agent.state.tools` 事实源,与 MNote 托管 Pi RPC/SSE、AiAccessScope 和 MNote-owned tools 边界冲突。
- 本轮采用 MNote-native adapter:视觉与交互参考官方 `ChatPanel/AgentInterface` 的消息流、composer、tool timeline、model/runtime controls 和 artifacts-style railsession、API key、模型、工具权限、receipt 仍由 MNote 后端托管。若下一阶段要直接复用官方组件,迁移条件是先提供不依赖浏览器 API key/IndexedDB/Agent tools 的 remote-session adapter。
- API endpoint smoke 当前覆盖禁用态 3000;完整 mock-mode smoke 需要用 `MNOTE_PAGE_AI_PI_LAB=1 MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock` 启动独立服务后运行。
## 4. Pi 是否进入下一阶段
建议进入下一阶段,但仍保持 internal/lab
- 通过:MNote 侧最小 runtime manager、tool facade、allowed roots、LightRAG facade、file patch、receipt adapter、SSE UI 已形成闭环。
- 未通过生产门槛:Turso receipt 表、正式登录态 browser smoke、watcher tiptap 刷新截图/断言仍未完整。
下一阶段应聚焦真实 runtime 和验收,不扩大默认入口。
## 5. OpenHub 状态
OpenHub 继续保持默认 Page AI 主线。Pi Lab 未替换、未破坏 OpenHub / LightRAG / Turso 路径;相关路由和 UI 均受 `MNOTE_PAGE_AI_PI_LAB=1` / `enable_page_ai_pi_lab` 控制。
## 6. 验证记录
- `node scripts/task-pi-lab-static-smoke.js`:通过,78 项。
- `node scripts/task-pi-lab-api-endpoint-smoke.js`:通过,10/10;当前 3000 服务未启用 Pi Lab,验证禁用态和 asset。
- `node scripts/task-pi-lab-mock-api-smoke.js`:通过;覆盖 mock runtime start、SSE events、allowed roots、当前页读取、越界拒绝、当前 `.md` patch、watcher refresh metadata、LightRAG facade、citation/open-reference facade、send、abort。
- `node scripts/task-pi-lab-browser-smoke.js`:通过;覆盖当前 MNote Page AI 抽屉、dev-only 悬浮 `π` 入口、Pi/OpenHub 切换、`omniroute/freefirst`、MNote tool rail、composer、status API 和截图;不再把独立 `/page-ai/pi` 当作主要 UI 验收面。
- Pi + Omniroute/freefirst 浏览器实测截图:`tmp/page-ai-pi-omniroute-freefirst-final-ok-20260704-v3.png`;证据 JSON`tmp/page-ai-pi-omniroute-freefirst-final-ok-20260704-v3.json`。最终 assistant 可见文本为 `OK``assistantStreaming=false``inDrawer=true`,模型为 `omniroute/freefirst`runtime 为 `rpc`
- `node scripts/task-pi-lab-mock-api-smoke.js`:通过;使用 mock mode 独立 3332 服务与 `MNOTE_PI_LAB_SMOKE_ROOT=/tmp/mnote-pi-lab-smoke`,覆盖 start/send/abort/events、allowed roots、越界拒绝、当前页 read、文件 patch、receipt。
- `node scripts/task-pi-lab-api-endpoint-smoke.js`:通过,10/10;支持启用态 + Bearer smoke auth,验证 status schema、disabled builtins、runtime asset 和端点存在。
- `codegraph sync . && codegraph status .`:通过;索引 up to date。
- 真实 Pi RPC smoke:通过;使用 `/tmp/mnote-pi-cli-wrapper.sh` 调用 `npx -y @earendil-works/pi-coding-agent@0.80.3`MNote 以 `MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc` / `MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh` 启动,验证 `start` 返回 `runtimePid`、当前页读取、越界拒绝、当前 `.md` patch、`send` accepted、`abort` 成功。
- `node scripts/task-pi-lab-rpc-api-smoke.js`:新增 RPC API smoke 固化脚本(2026-07-04 7-69 收尾),覆盖 runtimeMode=rpc status、runtimePid 真实子进程验证、start/send/abort 端点、tool-call MNote facadesallowed roots、越界拒绝、.md patch 含 watcher refresh/version change、LightRAG、citation)、SSE 事件流(runtime_started 含 pid/mode/disabledBuiltinTools)、多会话生命周期、Pi 进程清理。需要真实 Pi CLI + API key 完整测试 send 实际 AI 调用。
- `cd rust && cargo check -p mnote-web`:通过。
- `which pi`:无输出;本轮通过 `MNOTE_PAGE_AI_PI_BIN` 指向 wrapper 完成真实 RPC smoke。
## RPC 验证命令
```bash
# 启动 mnote-web(真实 Pi RPC 模式)
MNOTE_PAGE_AI_PI_LAB=1 \
MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc \
MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh \
MNOTE_PI_LAB_ALLOWED_ROOTS=/tmp/mnote-pi-rpc-smoke \
npm run desktop:hot
# 另一个终端运行 smoke
MNOTE_PI_LAB_BASE=http://127.0.0.1:3000 \
MNOTE_PI_LAB_AUTH="Bearer pi-lab-smoke" \
MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh \
MNOTE_PI_LAB_ALLOWED_ROOTS=/tmp/mnote-pi-rpc-smoke \
node scripts/task-pi-lab-rpc-api-smoke.js
```
mock 验证命令:
```bash
MNOTE_PAGE_AI_PI_LAB=1 \
MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock \
MNOTE_PI_LAB_ALLOWED_ROOTS=/tmp/mnote-pi-lab-smoke \
MNOTE_PI_LAB_BASE=http://127.0.0.1:3000 \
node scripts/task-pi-lab-mock-api-smoke.js
```
@@ -0,0 +1,51 @@
# 7-70 Page AI Pi Lab 原生 OpenHub-like UI Checklist v1
状态:doneUI spike + P2 Pi tool bridge 回归)
Owner07-ai / mnote-web / page-ai-runtime
日期:2026-07-04
## 1. 验收项
- [x] OpenHub 继续保持默认 Page AI 主线。
- [x] Pi Lab 默认显示独立 `π` 悬浮按钮,不以 `MNOTE_PAGE_AI_PI_LAB=1` 作为显示前置条件。
- [x] `MNOTE_PAGE_AI_PI_LAB` 保留为强制关闭开关,默认值为开启。
- [x] Pi Lab 点击后打开独立 MNote-native drawer。
- [x] Pi Lab 不挂载到 `[data-testid="wolai-page-ai-drawer"]`
- [x] Pi Lab 不 iframe 第二个完整应用。
- [x] Pi Lab 不复用 OpenHub provider tab、按钮或 iframe 页面。
- [x] topbar 显示 Pi Lab、session/runtime status、model、close/minimize。
- [x] context strip 显示当前页、选区、allowed roots、LightRAG、changed files。
- [x] message timeline 显示 user / assistant / system、streaming、aborted。
- [x] provider thinking 不进入 visible final answerreasoning 仍默认隐藏。
- [x] tool/receipt rail 显示 tool status、deny reason、receipt、file version、diff summary、citation count。
- [x] composer 支持多行输入、当前页/选区/LightRAG 快捷按钮、send、abort。
- [x] diagnostics 默认折叠,不挤占首屏。
- [x] MNote-owned tools 保留,不开放 Pi 原始 `bash/read/write/edit`
- [x] allowed roots 越界读写在 UI 中显示 denied 和 deny reason,并写 receipt。
- [x] LightRAG 仍是唯一默认 knowledge providerPi 只通过 MNote facade 查询。
- [x] patch 响应保持 watcher refresh / `polling=false` contract。
- [x] Pi 默认模型继续为 Omniroute `freefirst`
- [x] static smoke119 checks)、api endpoint smoke、mock/API smoke、RPC API smoke、browser smoke、cargo check、CodeGraph sync/status 已运行。
- [x] 真实 Pi RPC + Omniroute/freefirst UI 截图回归已补:`tmp/page-ai-pi-lab-rpc-browser-real-7-71.png`
- [x] 真实 `/documents/local-md:*` 文档页默认注入独立 Pi Lab `π` 入口:`tmp/page-ai-pi-lab-rpc-browser-p1-7-71c.png`
- [x] Pi Lab start session 绑定当前 root/page/title。
- [x] “当前页”快捷按钮调用 `mnote.current_page.read` 并读取当前 Markdown。
- [x] “选区”快捷按钮读取 tiptap live selection snapshot 并注入 composer。
- [x] `.md` patch 后通过 local-folder event bus / document refresh 更新当前 `.ProseMirror`,未新增高频轮询。
- [x] 修复 P1 审查发现的任意 Cookie 认证绕过、跨用户 session 查询、selection tool host snapshot 标识、`tool_receipt.write` 空操作和 pagePath smoke 误报。
- [x] Pi RPC subprocess 通过 per-session extension 注册 MNote-owned custom tools。
- [x] 真实 Pi 主动调用 `mnote_current_page_read`,并经 `/api/page-ai/pi/tool-call-bridge` 回到 MNote `mnote.current_page.read` 的 API smoke 已通过。
- [x] bridge token 使用 OS 随机源生成,不序列化到 session JSON,不写入 extension 文件。
- [x] bridge endpoint 拒绝非 running sessionaborted/error session 不能继续通过 bridge 调 tool。
- [x] session TTL、最大 session 数、start/send/tool rate limit 和过期 session 目录清理已补。
- [x] P2 真实浏览器截图已补:`tmp/page-ai-pi-lab-rpc-browser-p2-visible-reply-7-70.png`,并断言 assistant bubble 本身包含真实 Pi 回复。
- [x] Pi Lab UI 已按 OpenHub-like 方向收敛:设置、上下文、tools、receipts 默认折叠,主对话区优先,模型选择入口保留。
- [x] OpenHub-like 折叠 UI 截图已补:`tmp/page-ai-pi-lab-openhub-like-ui-collapsed-v2-7-70.png`
- [x] Pi Lab UI 已按 OpenHub 平台式首屏二次收敛:大标题、水平工具栏、白色对话卡片、居中空状态示例、底部大输入框与 Build/Plan/model/budget 控制条;截图:`tmp/page-ai-pi-lab-openhub-platform-empty-v4-7-70.png`
## 2. 下一阶段
- [ ] Turso/libSQL 正式 receipt 表。
- [ ] in-process `AgentSession` / Node sidecar SDK 方案评估,用于替代当前 RPC subprocess + extension bridge spike。
- [ ] production Pi binary 显式配置和版本锁定。
- [ ] session resume / retry / history drawer 产品化。
@@ -0,0 +1,93 @@
# 7-70 Page AI Pi Lab 原生 OpenHub-like UI 验收记录 v1
状态:doneUI spike + P2 Pi tool bridge 回归)
Owner07-ai / mnote-web / page-ai-runtime
日期:2026-07-04
## 1. 结论
7-70 已完成 Pi Lab 从 7-69 的“OpenHub 抽屉内 provider 面板”到“默认显示的独立 `π` 悬浮入口 + MNote-native 抽屉”的切换。OpenHub 继续作为默认 Page AI 主线,Pi Lab 不复用 OpenHub 按钮、iframe 页面、provider tab 或抽屉容器。
Pi Lab 可以进入下一阶段受控验证,但仍不替换 OpenHub。2026-07-04 已补跑真实 Pi RPC + Omniroute/freefirst 的浏览器回归,证明真实 Pi text stream 能进入 MNote-native timeline 且 provider thinking 不进入 visible final answer。随后补齐了文档页 `/documents/local-md:*` 默认注入独立 Pi Lab、当前页读取、tiptap live selection snapshot、allowed roots deny、`.md` patch 后 watcher/refresh 刷新当前 tiptap 页的浏览器回归。
P2 已补齐真实 Pi 主动调用 MNote-owned tools 的最小链路:由于 Pi RPC 协议没有 host 回写 `tool_result` 的 stdin command,当前 spike 采用 per-session Pi extension bridge 注册 `mnote_*` custom tools。Pi 子进程仍使用 `--no-builtin-tools` 禁用原始 `bash/read/write/edit`,只允许 `mnote_current_page_read` 等 MNote 工具名;extension execute 通过内部 `/api/page-ai/pi/tool-call-bridge` 回到 MNote `execute_tool`,由 MNote 执行 allowed roots / LightRAG facade / receipt 写入。真实 Omniroute/freefirst API smoke 已观察到 Pi 发出 `tool_execution_start: mnote_current_page_read`MNote bridge 执行 `mnote.current_page.read`,并返回最终 marker。
## 2. 已实现内容
- `sidebar-page-ai-pi-lab-runtime.js` 改为独立 drawer`[data-page-ai-pi-lab-launcher]` 默认显示,点击打开 `[data-page-ai-pi-lab="drawer"]`,不再调用 `attachPanelToDrawer` / `setOpenHubVisible`,不再出现 `data-page-ai-pi-lab-openhub-tab`
- UI 补齐 topbar、session/runtime/model/status、context strip、message timeline、composer、right rail receipts/tools、默认折叠 diagnostics2026-07-04 二次收敛为 OpenHub 平台式首屏:大标题、水平工具栏、白色对话卡片、居中空状态示例、底部大输入框与 Build/Plan/model/budget 控制条。
- `MNOTE_PAGE_AI_PI_LAB` 改为默认开启,保留为 `0/false` 强制关闭开关;后端 `status` 返回 `uiMode=independent_mnote_native_drawer`
- 保留 MNote-owned tool set 和 Pi builtin 禁用策略:`bash/read/write/edit` 仍默认不可用。
- tool event UI 补充 deny reason、diff summary、file version、citation count、changed files chip。
- mock runtime send 事件补充 LightRAG mock citation 与 diff event,用于浏览器截图验证 stream/citation/diff 的 UI 形态。
- OpenHub 回归通过浏览器 smoke 验证:OpenHub drawer API 仍存在,OpenHub iframe host 仍独立打开,Pi drawer 不在 OpenHub drawer 内。
- 文档页 shell 现在在 `enable_page_ai_pi_lab` 开启时注入 Pi Lab runtime,因此真实 `/documents/local-md:*` 页面默认也显示独立 `π` 入口。
- Pi Lab start session 会绑定当前 `rootUri / workspaceId / pagePath / pageTitle`
- “当前页”快捷按钮实际调用 `mnote.current_page.read`,并把当前 Markdown 内容注入 composer / system timeline。
- “选区”快捷按钮消费 `window.getSelection()``mnote:leptos-tiptap-spike:selection` 快照,已在真实 tiptap 页面验证选中文本注入 composer。
- `mnote.local_file.patch` tool event 后会走 local-folder event bus + document pane refresh,不新增轮询;真实浏览器截图已验证当前 `.md` patch 后 `.ProseMirror` 显示新内容。
- 每个 Pi session 生成独立 tool bridge extension,注册 `mnote_current_page_read / mnote_selection_read / mnote_allowed_roots_describe / mnote_local_file_read / mnote_local_file_patch / mnote_knowledge_rag_query / mnote_reference_open / mnote_tool_receipt_write`
- bridge token 使用 `/dev/urandom` 生成,不随 session JSON 序列化,不写入 extension 文件;token 通过子进程 env 注入。
- `/api/page-ai/pi/tool-call-bridge` 只接受匹配 bridge token 且仍处于 `RuntimeRunning/TurnRunning` 的 session。
- session TTL、最大 session 数、start/send/tool 简单 rate limit 已补;过期 session 目录和空 rate bucket 会清理。
## 3. 第三方 UI 复用决策
- `@earendil-works/pi-web-ui` 继续作为 ChatPanel / AgentInterface / ThinkingBlock / tool renderer / artifacts rail 的交互参考,不直接引入 MNote 主链。
- `@assistant-ui/react-pi` 继续作为 HTTP/SSE thread contract 参考,不让其接管 MNote provider key、IndexedDB session、Pi browser Agent 或 tool 权限。
- `ai-elements` 继续作为 message / reasoning / tool / source / prompt input 形态参考,不引入 React/Tailwind/shadcn 依赖。
## 4. 验证记录
- `node scripts/task-pi-lab-static-smoke.js`:通过,119 checks。
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33170 MNOTE_PI_LAB_AUTH='Bearer pi-lab-smoke' node scripts/task-pi-lab-api-endpoint-smoke.js`:通过,10/10。
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33170 MNOTE_PI_LAB_AUTH='Bearer pi-lab-smoke' MNOTE_PI_LAB_SMOKE_ROOT=/tmp/mnote-pi-lab-smoke node scripts/task-pi-lab-mock-api-smoke.js`:通过。
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33170 MNOTE_PI_LAB_AUTH_HEADER='Bearer pi-lab-smoke' MNOTE_PI_LAB_BROWSER_ROOT=/tmp/mnote-pi-lab-browser-smoke MNOTE_PI_LAB_SCREENSHOT=/mnt/Data1T/mnote/tmp/page-ai-pi-lab-native-drawer-7-70.png node scripts/task-pi-lab-browser-smoke.js`:通过。
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33172 MNOTE_PI_LAB_AUTH='Bearer pi-lab-smoke' MNOTE_PI_LAB_BROWSER_ROOT=/tmp/mnote-pi-real-rpc-smoke-1783130268 MNOTE_PI_LAB_SCREENSHOT=/mnt/Data1T/mnote/tmp/page-ai-pi-lab-rpc-browser-real-7-71.png node scripts/task-pi-lab-rpc-browser-smoke.js`:通过;使用 `MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc``MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh` 和 Omniroute `freefirst`
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33172 MNOTE_PI_LAB_AUTH='Bearer pi-lab-smoke' MNOTE_PI_LAB_BROWSER_ROOT=/tmp/mnote-pi-p1-rpc-smoke-1783133999 MNOTE_PI_LAB_SCREENSHOT=/mnt/Data1T/mnote/tmp/page-ai-pi-lab-rpc-browser-p1-7-71c.png node scripts/task-pi-lab-rpc-browser-smoke.js`:通过;覆盖真实文档页 Pi Lab 默认入口、当前页读取、tiptap selection、allowed roots deny、`.md` patch、watcher/refresh 到 `.ProseMirror`、真实 Pi stream、abort、非 iframe;同时收紧 pagePath 绑定断言。
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33172 MNOTE_PI_LAB_AUTH='Bearer pi-lab-smoke' MNOTE_PI_LAB_SMOKE_ROOT=/tmp/mnote-pi-real-rpc-smoke-1783130268 MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh node scripts/task-pi-lab-rpc-api-smoke.js`:通过;覆盖真实 Pi RPC subprocess、send、SSE、abort、allowed roots deny、`.md` patch、receipt。
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33172 MNOTE_PI_LAB_AUTH='Bearer pi-lab-smoke' OPENAI_API_KEY=<codex auth> node scripts/task-pi-lab-rpc-api-smoke.js`:通过;覆盖真实 Pi custom tool 主动调用 `mnote_current_page_read`、MNote bridge 执行 `mnote.current_page.read`、marker 返回、send、SSE、abort、allowed roots deny、`.md` patch、receipt。
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33172 MNOTE_PI_LAB_AUTH='Bearer pi-lab-smoke' MNOTE_PI_LAB_SCREENSHOT=/mnt/Data1T/mnote/tmp/page-ai-pi-lab-rpc-browser-p2-visible-reply-7-70.png node scripts/task-pi-lab-rpc-browser-smoke.js`:通过;覆盖真实浏览器 Pi Lab 默认入口、原生 drawer、当前页/选区、allowed roots deny、`.md` patch watcher refresh、真实 Pi stream、assistant bubble 可见回复、abort、非 iframe。该 smoke 已修正为断言 assistant bubble 本身包含 marker,而不是误用 drawer 全文里的用户 prompt。
- `MNOTE_PI_LAB_BASE=http://127.0.0.1:33172 MNOTE_PI_LAB_AUTH='Bearer pi-lab-smoke' MNOTE_PI_LAB_SCREENSHOT=/mnt/Data1T/mnote/tmp/page-ai-pi-lab-openhub-like-ui-collapsed-v2-7-70.png node scripts/task-pi-lab-rpc-browser-smoke.js`:通过;覆盖 OpenHub-like 折叠 UI,设置/上下文/右侧 tools/receipts 默认收起,主对话区成为视觉重点,同时保留模型选择入口和真实 Pi 可见回复。
- `curl -H 'Cookie: foo=bar' http://127.0.0.1:33172/api/page-ai/pi/status`:返回 401 `page_ai_pi_lab_invalid_session_cookie`,验证任意 Cookie 不能绕过 Pi Lab API 认证。
- `cd rust && cargo check -p mnote-web`:通过。
- `codegraph sync . && codegraph status .`:通过,index up to dateCodeGraph 提示索引由旧 engine 构建,可后续择机 full re-index。
## 5. 截图证据
- Pi Lab 原生抽屉:`tmp/page-ai-pi-lab-native-drawer-7-70.png`
- 显示独立 `π` 悬浮按钮。
- 显示独立 Pi Lab drawer,不是 iframe。
- 显示 `omniroute/freefirst`、runtime `mock`、aborted、context strip、tool rail、receipt rail。
- 显示 prompt、mock stream、abort、LightRAG mock citation、patch/diff、changed files、allowed roots deny receipt。
- OpenHub 默认入口回归:`tmp/page-ai-pi-lab-openhub-default-7-70.png`
- 显示 OpenHub drawer 仍可独立打开。
- 同屏保留独立 `π` 入口,说明 Pi Lab 未复用 OpenHub 抽屉容器。
- 真实 Pi RPC 浏览器回归:`tmp/page-ai-pi-lab-rpc-browser-real-7-71.png`
- 显示 runtime `rpc`、模型 `omniroute/freefirst`
- 显示真实 Pi RPC text stream 返回 `REAL_PI_BROWSER_OK_*` 标记。
- provider thinking 事件仅进入 diagnostics,不进入 visible final answer。
- 同时验证 allowed roots deny、markdown patch receipt、abort UI 状态和非 iframe 独立 drawer。
- P1 文档页功能回归:`tmp/page-ai-pi-lab-rpc-browser-p1-7-71c.png`
- 显示真实 `/documents/local-md:*` 页面默认存在独立 `π` 入口。
- 显示 Pi Lab drawer 内绑定当前页、选区、runtime `rpc`、模型 `omniroute/freefirst`
- 显示当前页已由 patch 从 `Browser RPC Original` 刷新为 `Browser RPC Patched`
- 显示 allowed roots deny receipt、patch diff receipt、changed files chip。
- P2 真实 Pi RPC 浏览器回归:`tmp/page-ai-pi-lab-rpc-browser-p2-visible-reply-7-70.png`
- 显示真实 MNote 文档页默认独立 `π` 入口与独立 Pi Lab drawer。
- 显示 runtime `rpc`、模型 `omniroute/freefirst`、Pi builtin `bash/read/write/edit` disabled。
- 显示当前页读取、选区、allowed roots deny receipt、patch diff receipt、changed files chip、assistant 可见回复。
- OpenHub-like 折叠 UI 回归:`tmp/page-ai-pi-lab-openhub-like-ui-collapsed-v2-7-70.png`
- 显示设置、上下文、MNote tools、Receipts 默认折叠。
- 显示模型选择入口 `omniroute/freefirst`,主 timeline 和 composer 保持首屏重点。
- OpenHub 平台式空状态回归:`tmp/page-ai-pi-lab-openhub-platform-empty-v4-7-70.png`
- 显示独立 Pi Lab 抽屉使用 OpenHub 平台式标题、顶部工具栏、白色对话卡片、居中问号空状态和五个示例。
- 设置、上下文、诊断默认不占首屏;通过齿轮展开。
- 底部保留大输入框、发送按钮、Build/Plan、`omniroute/freefirst` 模型选择、预算和当前页/选区快捷入口。
## 6. 未完成内容与风险
- receipt 仍落 `provider_neutral_jsonl_adapter_v1`,尚未迁移到 Turso/libSQL 正式 `ai_provider_sessions` / `ai_tool_events` / `ai_file_patches` 表。
- 真实 Pi RPC + Omniroute/freefirst 已补浏览器回归;真实 Pi 主动调用 MNote tools 的最小 bridge 链路已在 API smoke 中通过。长期更正统方向仍是 in-process `AgentSession` / Node sidecar SDK,避免依赖 RPC subprocess + extension bridge。
- watcher refresh 已通过真实文档页 `.md` patch 后 `.ProseMirror` 更新验证;仍需在后续产品化中补更细的 file version 冲突与多 pane 场景。
- P1/P2 审查后已修复任意 Cookie 认证绕过、跨用户 session 查询、selection tool 缺少 host snapshot 标识、`tool_receipt.write` 空操作、browser smoke pagePath 误报、bridge token 可预测、aborted session bridge 调用、rate bucket 增长和 token 落盘风险。
@@ -0,0 +1,499 @@
# 7-70 Page AI Pi Lab 原生 OpenHub-like UI 设计 v1
状态:done
Owner07-ai / mnote-web / page-ai-runtime
日期:2026-07-04
## 1. 结论
Pi Lab 下一阶段不做第二个 iframe,也不把 `@earendil-works/pi-web-ui` 或 LibreChat / Open WebUI 这类完整应用直接嵌入 MNote。正确方向是:
```text
MNote Page AI 独立 Pi Lab 浮动入口 + 原生抽屉 UI
-> 体验参考当前 OpenHub AI 面板,但入口和容器不依赖 OpenHub
-> 运行协议参考 @assistant-ui/react-pi 的 PiClient / HTTP SSE thread contract
-> 消息、composer、tool、artifact 形态参考 Pi 官方 web-ui 与 ai-elements
-> 权限、知识库、receipt、watcher refresh 继续由 MNote 托管
```
OpenHub 继续作为默认 Page AI 主线。Pi Lab 使用独立浮动按钮与独立抽屉容器,默认在 MNote 页面中显示;目标是把 7-69 的 debug spike UI 升级成可验证的 MNote-native Page AI 体验,而不是替代或复用 OpenHub 的入口。若后续确认不需要 Pi Lab,直接退役这一独立入口和 runtime 即可,不需要先用 feature flag 隔离。
## 2. 原则
- **独立入口默认显示**:Pi Lab 新增自己的悬浮按钮和抽屉容器,默认显示,不复用当前 OpenHub 的按钮、iframe 页面或 provider tab 容器;以后退役 OpenHub 或 Pi Lab 任一侧,都不影响另一侧。
- **原生承载**Pi Lab UI 渲染在 MNote 自有 DOM 中,不新增独立产品页作为主验收面,不 iframe 第二个完整 AI 应用。
- **优先参考和复用**:能复用当前 OpenHub 已证明的交互、Pi 官方 UI 的事件语义、assistant-ui 的 runtime contract、ai-elements 的组件形态,就不要自造一套孤立协议。
- **MNote 拥有真相**session binding、allowed roots、LightRAG facade、tool receipt、file patch、citation/open-reference、watcher refresh 仍归 MNote;第三方 UI 不能接管这些事实源。
- **Debug 后置**Page AI 首屏必须是对话、输入、上下文和变更结果;runtime health、scope JSON、receipt 原文只进折叠诊断区或开发态右栏。
- **不破坏 OpenHub**OpenHub 仍是默认入口和默认 Page AI 主线;Pi Lab 作为独立入口与 OpenHub 并列但互不依赖,不出现普通用户可见的 fallback/迁移暗示。
## 3. 参考资源取证
### 3.1 当前 OpenHub UI
本地 OpenHub 是当前最接近目标体验的产品基线:
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/pages/SmartQueryPage.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/components/ChatInput.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/components/AssistantMessage.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/components/ToolCall.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/components/HistoryDrawer.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/components/DiffViewer.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/components/KnowledgeManager.jsx`
可参考能力:
- 顶部/底部紧凑控制区:agent mode、model select、当前页面/当前文件夹按钮。
- 主消息流:user / assistant bubble、streaming、retry / undo、idle state。
- assistant messagereasoning 折叠、tool summary、tool cards、citation chips。
- session/history:历史抽屉、恢复会话、归档。
- changed files / diff:隐藏索引桥接 MNote,用户侧展示 changed files / diff 入口。
- knowledge:知识库入口和 citation 回跳模式。
不直接复用方式:
- 不把 OpenHub React/AntD 组件直接搬进 `mnote-web` 主运行时。
- 不复制 OpenHub session/message 全文存储作为 Pi Lab 真相。
- 不复用 OpenHub iframe shell 实现 Pi Lab,因为用户目标是 MNote-native。
### 3.2 Pi 官方 web-ui
`@earendil-works/pi-web-ui@0.75.3` 已核验为 MIT 包,描述为 “Reusable web UI components for AI chat interfaces powered by @earendil-works/pi-ai”。包内包含:
- `ChatPanel`
- `AgentInterface`
- `MessageList` / `Messages`
- `MessageEditor`
- `StreamingMessageContainer`
- `ThinkingBlock`
- tool renderers
- artifacts runtime / sandbox
- `ModelSelector`
- IndexedDB storage stores
可参考能力:
- Pi 消息流与工具流的视觉分层。
- thinking / tool / artifact 的独立组件边界。
- composer 与 model/thinking selector 的组合方式。
- artifacts-style 侧栏。
不直接整包接入原因:
- 默认假设 browser 侧持有 `Agent`、provider keys、IndexedDB session/settings。
- 默认可暴露 Pi 自带 tools / attachments / artifacts 事实源。
- 与 MNote 后端托管 Pi RPC/SSE、AiAccessScope、MNote-owned tools 的边界冲突。
迁移条件:
- 先提供 remote-session adapter,使 `ChatPanel` / `AgentInterface` 不直接管理 provider key、IndexedDB 和 `Agent.state.tools`
- 所有 tool call、file read/write、knowledge query、receipt 仍必须走 MNote facade。
### 3.3 assistant-ui / react-pi
`@assistant-ui/react-pi@0.0.5` 已核验为 MIT,说明其 browser entry 是 RPC-isomorphic `PiClient`,通过 HTTP/SSE 驱动 Pi-backed threads。其 contract 包含:
```text
GET /threads
POST /threads
GET /threads/:id
PATCH /threads/:id
POST /threads/:id/messages
POST /threads/:id/cancel
GET /models
POST /threads/:id/model
POST /threads/:id/thinking
GET /threads/:id/events
```
可参考能力:
- thread / message / composer / queue / cancel 的协议边界。
- snapshot-first SSE reconnect 语义。
- model / thinking controls。
- host UI approval requests。
- tool-associated approval / interrupt 的 UI 映射。
当前不直接引 React UI 的原因:
- `mnote-web` 当前主路径是 Rust SSR + browser JS,不是 React app。
- 直接引入 React island、Tailwind/shadcn 体系会扩大构建和运行时边界。
- 当前优先目标是把 MNote-owned Pi adapter 做完整,而不是重开前端框架主线。
设计决策:**先对齐 contract,不先引 UI 框架**。后续如果 MNote 需要 React island,再以该 contract 为迁移桥。
### 3.4 ai-elements
`ai-elements@1.9.0` 已核验为 Apache-2.0,定位是基于 shadcn/ui 的 AI-native component registry。
可参考能力:
- message / conversation / prompt input / reasoning / tool / source / response card 的组件形态。
- 更现代的 AI chat 视觉密度和状态表达。
当前不直接接入原因:
- 依赖 React、Tailwind、shadcn registry 和 CSS variables。
- 对当前 MNote Rust SSR + browser JS 主路径引入成本偏高。
设计决策:作为视觉和组件拆分参考,不作为 7-70 第一阶段依赖。
### 3.5 完整应用类参考
LibreChat、Open WebUI、AnythingLLM、Continue、Cline、Roo Code 只作为交互模式参考:
- LibreChat / Open WebUI / AnythingLLM:参考历史会话、多模型、RAG、文件区、设置入口。
- Continue / Cline / Roo Code:参考 agent tool timeline、审批、diff、changed files、任务状态。
不作为直接依赖或嵌入目标。
## 4. UI 目标结构
### 4.1 独立入口与抽屉整体
```text
MNote page
Pi Lab floating buttondefault visible
-> Pi Lab native drawerindependent from OpenHub iframe/page
topbar
Pi Lab title
session title
runtime status
close / minimize
context strip
current page chip
selection chip
allowed roots chip
LightRAG chip
model / thinking selector
body
message timeline
optional right rail
composer
prompt input
context buttons
send / abort / queue state
folded diagnostics
```
桌面端允许右侧 rail;移动端不显示右 rail,改为底部 sheet 或折叠 section。
入口规则:
- Pi Lab 使用独立悬浮按钮,例如右下角 `π`,默认渲染。
- Pi Lab 点击后打开独立抽屉,不借用 OpenHub 当前按钮、OpenHub iframe 页或 OpenHub provider tab。
- Pi Lab 抽屉可以视觉参考 OpenHub,但 DOM、状态、session、事件订阅、关闭/最小化都独立。
- OpenHub 的现有按钮和页面保持不变;Pi Lab 不在 OpenHub 面板内新增 tab 作为主入口。
- 若未来退役 OpenHub,Pi Lab 浮动入口仍可保留;若退役 Pi Lab,OpenHub 入口不受影响。
- `MNOTE_PAGE_AI_PI_LAB` 可保留为临时开发/强制关闭开关,但不是产品入口显示的前置条件。
### 4.2 Topbar
目标是接近 OpenHub 的 AI 面板,而不是 debug 工具页。
必须包含:
- Pi Lab 标题与实验标记;不放 OpenHub/Pi provider tabs 作为主导航。
- session label`Pi Lab · 当前页面标题`,恢复会话后显示短 session id。
- runtime statusidle / starting / streaming / error。
- model label:默认 `omniroute/freefirst`,可后续扩展 selector。
- close/minimize。
不要在 topbar 放大段 `scope``disabled builtins`、raw env 等诊断文案。
### 4.3 Context Strip
替代当前偏 debug 的工具列表,参考 OpenHub `ChatInput` 中当前页面/文件夹按钮:
- 当前页:真实 `.md` 路径或页面标题,点击在 MNote 中定位。
- 选区:无选区时 disabled,有选区时显示字符数。
- allowed roots:显示 root 名称和数量,点击展开只读详情。
- LightRAG:显示 provider 状态;query citation 走 `mnote.reference.open`
- 文件变更:当前 session 有 patch 后显示 changed files chip。
Context strip 是用户可理解的任务上下文,不展示 JSON。
### 4.4 Message Timeline
消息模型要吸收 OpenHub `AssistantMessage` 与 Pi/assistant-ui 的 part 语义:
```text
MNotePiUiMessage
id
role = user | assistant | system
status = queued | streaming | done | error | aborted
createdAt
model
parts[]
MNotePiUiPart
text
reasoning
toolCall
toolResult
citation
diff
fileChange
approval
error
```
展示规则:
- user message 右侧气泡。
- assistant message 左侧主体块,正文优先。
- reasoning 默认折叠,严禁把 provider `reasoning_content` 混入 final answer。
- tool timeline 默认折叠成 summary,运行中自动展开当前 tool。
- tool 输入/输出长 JSON 默认收起,显示摘要和 deny reason。
- citation 显示为来源 chip,点击走 MNote open-reference。
- diff / fileChange 显示为 changed file chips + diff summary,点击走 MNote document pane / diff view。
- error 明确显示 provider/runtime/tool deny,不伪装成功。
### 4.5 Tool Timeline
参考 OpenHub `ToolCall.jsx`、Pi official tool renderers、Cline/Roo 的工具审批流。
第一阶段必须覆盖:
- `mnote.current_page.read`
- `mnote.selection.read`
- `mnote.allowed_roots.describe`
- `mnote.local_file.read`
- `mnote.local_file.patch`
- `mnote.knowledge_rag.query`
- `mnote.reference.open`
- `mnote.tool_receipt.write`
每个 tool card 至少显示:
- tool name
- statepending / running / allowed / denied / error / done
- compact params summary
- deny reason
- receipt id
- file version before / after
- diff summary
- citation count
默认禁用 Pi 原始 `bash/read/write/edit` 的状态可以放在折叠详情里,不占据首屏。
### 4.6 Composer
参考 OpenHub `ChatInput`、Pi `MessageEditor`、assistant-ui composer queue 语义。
第一阶段:
- 多行输入。
- icon-only 当前页、选区、文件夹/allowed roots、LightRAG 按钮,带 tooltip 和 aria-label。
- send 按钮。
- streaming 时显示 abort;若后端支持 queue,再允许 follow-up queue。
- 发送前自动附带 MNote context envelope,不把上下文 URL 或 JSON 写入 textarea。
- 禁用态要有明确原因:未启动 runtime、missing model、missing auth、scope denied。
后续阶段:
- model selector。
- thinking level selector。
- thread queue / steer。
- prompt template / skill picker。
### 4.7 Right Rail / Drawer
桌面端右侧 rail 承接“有用但不该挤占主对话”的信息:
- session/history:当前只显示当前 session 与最近 session,完整历史后续做抽屉。
- changed files:变更文件列表、patch count、打开按钮。
- citations:本轮 citation 列表。
- receipts:最近 tool receipts,默认摘要。
- runtimePi PID / provider session id / runtime mode,只在 dev mode 显示。
移动端右 rail 不常驻,改为按钮展开底部 sheet。
### 4.8 Diagnostics
折叠 diagnostics 只面向开发:
- raw SSE events tail。
- allowed roots raw snapshot。
- receipt raw JSON。
- runtime command / pid / session dir。
- disabled builtin tools。
默认折叠,不参与普通用户验收截图。
## 5. 后端与协议设计
### 5.1 保留现有 7-69 endpoint
当前 7-69 已有:
- `/api/page-ai/pi/status`
- `/api/page-ai/pi/bootstrap`
- `/api/page-ai/pi/start`
- `/api/page-ai/pi/send`
- `/api/page-ai/pi/abort`
- `/api/page-ai/pi/events`
- `/api/page-ai/pi/tool-call`
- `/page-ai/pi`
7-70 不要求立即破坏这些 endpoint。
但前端入口要调整为默认显示的独立浮动按钮与独立 drawer host`/page-ai/pi` 可保留为 debug/internal 直达页,不作为产品主验收入口。
### 5.2 新增 PiClient-aligned adapter 层
为了后续能接 `@assistant-ui/react-pi` 或复用其 reducer 语义,新增 provider-neutral adapter contract
```text
GET /api/page-ai/pi/threads
POST /api/page-ai/pi/threads
GET /api/page-ai/pi/threads/:id
POST /api/page-ai/pi/threads/:id/messages
POST /api/page-ai/pi/threads/:id/cancel
GET /api/page-ai/pi/threads/:id/events
GET /api/page-ai/pi/models
POST /api/page-ai/pi/threads/:id/model
POST /api/page-ai/pi/threads/:id/thinking
```
兼容策略:
- 第一阶段可在前端 adapter 中把旧 endpoint 映射为 thread contract。
- 后端可逐步增加新 endpoint;旧 smoke 不被破坏。
- SSE 必须 snapshot-first:重连先发当前 thread snapshot,再发 live events。
- browser disconnect 不等于 abort;只有 cancel/abort 明确停止 runtime。
### 5.3 UI event normalization
Pi RPC / mock / future SDK event 统一归一为:
```text
thread_snapshot
message_created
message_delta
message_completed
reasoning_delta
tool_call_started
tool_call_updated
tool_call_completed
tool_call_denied
citation_added
file_patch_applied
receipt_written
runtime_status_changed
thread_error
```
所有 UI 只消费 normalized events,不直接依赖 Pi JSONL 原始字段。
## 6. 数据与权限边界
- `AiAccessScope` / allowed roots 是所有 file tool 的唯一授权来源。
- Pi Lab UI 只显示 allowed roots 摘要,不允许用户从 UI 临时扩大 root。
- `mnote.local_file.read` / `patch` 必须返回 normalized path、root id、deny reason、file version。
- LightRAG 继续是唯一默认 knowledge providerPi 只能通过 `mnote.knowledge_rag.query` 查询。
- citation 点击必须走 `mnote.reference.open` / MNote open-reference,不直接让 Pi 打开文件。
- receipt 当前仍可落 provider-neutral adapter,但 UI v2 必须按 Turso/libSQL 迁移目标设计字段。
- 文件 patch 后必须通过 MNote watcher / document refresh 链路更新 tiptap;禁止新增轮询刷新。
## 7. 实施阶段
### Phase ANative UI v2 骨架
修改范围:
- `rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js`
- `scripts/task-pi-lab-static-smoke.js`
- `scripts/task-pi-lab-browser-smoke.js`
- 必要时补 `rust/crates/mnote-web/src/ssr/styles/components/page-ai.css`
目标:
- 当前 Pi Lab 从 debug shell 升级为 OpenHub-like 抽屉。
- Pi Lab 使用独立悬浮按钮打开独立抽屉,不复用 OpenHub 按钮、OpenHub iframe 页面或 provider tab。
- 首屏只保留对话、context strip、composer。
- tool receipts / diagnostics 默认后置。
- 截图必须显示:在 MNote 当前页面中通过独立 `π` 浮动按钮打开 Pi Lab 原生抽屉,非 OpenHub iframe、非独立页、非 OpenHub provider tab。
### Phase BEvent / message model 收口
修改范围:
- `sidebar-page-ai-pi-lab-runtime.js`
- `rust/crates/mnote-web/src/routes/page_ai_pi.rs`
- smoke 脚本
目标:
- 新增 normalized event reducer。
- reasoning、tool、citation、diff、receipt 不再混杂在一条 debug 文本里。
- SSE reconnect snapshot-first。
- abort/cancel 明确区分 browser disconnect。
### Phase CPiClient-aligned adapter
目标:
- 增加或前端模拟 `/threads` contract。
- `send` / `cancel` / `events` 对齐 `@assistant-ui/react-pi` 的语义。
- 保持旧 endpoint 兼容,直到 smoke 全部迁移。
### Phase DOpenHub-like 功能补齐
目标:
- session history 抽屉。
- changed files rail。
- citation list。
- diff preview。
- model / thinking selector。
- tool approval / host UI requests。
### Phase E:第三方组件复用决策
进入条件:
- Phase A-D 浏览器截图和 smoke 通过。
- 当前 MNote native UI 已形成稳定 message/event model。
决策:
- 若 MNote 仍保持非 React 主路径:继续原生实现,只参考 Pi web-ui / ai-elements。
- 若引入 React island:优先评估 `assistant-ui + @assistant-ui/react-pi`,并复用 Phase C contract。
- 若官方 Pi web-ui 提供 remote-session adapter:评估局部复用 `AgentInterface` / `Messages` / `MessageEditor`,不得引入 browser provider key 和 IndexedDB 作为 MNote 真相。
## 8. 验收标准
- OpenHub 默认入口不变。
- MNote 默认显示独立 Pi Lab 浮动按钮;点击后打开独立 Pi Lab 原生抽屉。
- Pi Lab 不复用 OpenHub 按钮、OpenHub 页面或 OpenHub iframe 容器。
- UI 首屏接近 OpenHub:消息流、上下文、输入框、模型/状态、tool/citation/diff 摘要齐全。
- 不出现第二个 iframe AI 应用。
- 可发送 prompt、接收 stream、abort。
- reasoning 折叠且不泄漏到 final answer。
- tool call 显示状态、deny reason、receipt id。
- allowed roots 越界读写在 UI 中显示 denied。
- LightRAG citation 可点击回跳。
- 当前 `.md` patch 后显示 changed file,并通过 watcher refresh 当前 tiptap 页。
- diagnostics 默认折叠。
- browser smoke 输出截图。
## 9. 明确非目标
- 不替换 OpenHub。
- 不把 Pi Lab 做成普通用户默认入口。
- 不 iframe LibreChat / Open WebUI / AnythingLLM / Pi web-ui demo app。
- 不把 Pi Lab 做成 OpenHub 面板内部的 provider tab 或复用 OpenHub 页面容器。
- 不把 React / shadcn / Tailwind 作为 7-70 Phase A 必需依赖。
- 不开放 Pi 原始 bash、无约束 read/write/edit。
- 不新增第二套 RAG。
- 不把 OpenHub 或 Pi 的 session JSONL 作为 MNote control-plane 长期真相。
## 10. 当前后续任务
- [ ] Phase A:重做 `sidebar-page-ai-pi-lab-runtime.js` 信息架构、独立浮动入口与视觉层级。
- [ ] Phase A:更新 browser smoke,截图验证独立 `π` 浮动按钮打开 OpenHub-like Pi Lab 抽屉。
- [ ] Phase B:抽出 normalized event reducer。
- [ ] Phase C:补 PiClient-aligned thread adapter。
- [ ] Phase D:补 changed files / diff / citation rail。
- [ ] Phase E:重新评估是否引入 `assistant-ui` 或 Pi official remote-session adapter。
@@ -0,0 +1,141 @@
# 7-71 统一 AI 管理面板与 Pi Lab 功能接入 — 实施检查清单
状态:partial — P0/P1/P2 完成;P3/P4/P5 管理员侧 OpenHub-like AI 管理与逐用户配置完成,用户自助偏好与 LightRAG 写管理待后续
日期:2026-07-04
> 本清单与 `design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md` 的 §11 进度表保持同步。
> process 文档保留架构结论;本文件记录已完成的检查项供快速验证。
## 已通过验收项
### P0 — 合同与只读聚合 ✅
| 检查项 | 通过 |
|---|---|
| `/api/ai-settings/effective` GET 端点已注册 | ✅ |
| `/api/ai-settings/access-scopes` GET 端点已注册 | ✅ |
| `/api/ai-admin/access-scopes` GET 端点已注册 | ✅ |
| 所有 AI settings/scopes 端点无 POST/PUT/DELETE | ✅ |
| `effective_settings` 使用 `load_active_directory_grants` 生成 allowed roots | ✅ |
| `load_model_policy_and_quota` 不读取 `allowed_roots_json` | ✅ |
| `source_of_truth` 声明为 `"directory_grants"` | ✅ |
| 静态 smoke 27/27 通过 | ✅ |
### P1 — Pi Lab history 入库 ✅
| 检查项 | 通过 |
|---|---|
| `persist_upsert_run` 写入 `ai_runtime_runs` | ✅ |
| `persist_append_event` 写入 `ai_runtime_events` | ✅ |
| `GET /api/page-ai/pi/sessions` 路由注册 | ✅ |
| `GET /api/page-ai/pi/sessions/{id}` 路由注册 | ✅ |
| `GET /api/page-ai/pi/sessions/{id}/events` 路由注册 | ✅ |
| list_sessions 按 pi_lab profile + pi acp_runtime 过滤 | ✅ |
### P2 — Receipt 与 patch 入库 ✅
| 检查项 | 通过 |
|---|---|
| migration v9 新增 `ai_tool_events / ai_file_patches` | ✅ |
| SQLite 与 Turso/libSQL store 均实现 append/list | ✅ |
| Pi tool receipt 默认写 control-plane | ✅ |
| 成功 patch 关联写入 `ai_file_patches` | ✅ |
| JSONL 仅在 control-plane 写失败时作为 debug fallback | ✅ |
| deny reason、diff summary、file version、citation count 可查询 | ✅ |
| receipt 与 patch 按当前用户隔离 | ✅ |
### P3 — AI 管理中心与管理员模型写管理 ✅
| 检查项 | 通过 |
|---|---|
| `/admin/ai` 路由与管理员鉴权 | ✅ |
| `/user/ai` 路由与用户鉴权 | ✅ |
| 账户菜单有独立 AI 管理入口 → `/admin/ai``/user/ai` | ✅ |
| AI 管理页面 SSR 渲染 OpenHub-like 左侧分页与多 section | ✅ |
| Access Scopes 只读展示(无 POST/PUT/DELETE、无写表单) | ✅ |
| Sessions & Receipts 显示真实 Pi session、receipt、patch 数量 | ✅ |
| Providers & Models 管理写操作 | ✅ |
| `/user/ai` 用户可操作设置 | ❌ |
### 独立入口保留检查
| 检查项 | 通过 |
|---|---|
| OpenHub `/page-ai/openhub/ai` | ✅ |
| OpenHub admin guard 路由 | ✅ |
| OpenHub status API | ✅ |
| Pi Lab shell `/page-ai/pi` | ✅ |
| Pi Lab event stream `GET /api/page-ai/pi/events` | ✅ |
| Pi Lab start/send/abort 路由 | ✅ |
### P4 — 工具、Skills/MCP 管理 ✅
| 检查项 | 通过 |
|---|---|---|
| `GET/PUT /api/ai-admin/settings` 原子策略端点 | ✅ |
| 模型 provider、allowed models、角色模型与 failover 写入 | ✅ |
| MNote-owned tools allow/ask/deny 写入 | ✅ |
| Skill 添加、描述、启停、删除 | ✅ |
| MCP transport/command/url/network/secret refs 写入 | ✅ |
| MCP 强制 facade-only 与 sandbox | ✅ |
| 刷新后配置持久存在 | ✅ |
| effective API 投影模型、Skills、MCP | ✅ |
### P5 — 逐用户 AI 配置 ✅
| 检查项 | 通过 |
|---|---|
| OpenHub `UserManagement`、模型/工具/Skill 权限实现已核验 | ✅ |
| `GET /api/ai-admin/users` 用户列表 | ✅ |
| `GET/PUT /api/ai-admin/users/{user_id}/settings` | ✅ |
| 用户可独立选择管理员允许的模型和默认模型 | ✅ |
| 用户工具策略只能相对全局降权 | ✅ |
| 用户 Skill/MCP 只能在全局启用目录中开关 | ✅ |
| 普通用户登录后的 effective API 应用 override | ✅ |
| allowed roots 仍只来自 `directory_grants` | ✅ |
| 用户抽屉采用 OpenHub-like 分组:模型按 provider、工具按 risk、Skill/MCP 开关、目录只读跳统一授权页 | ✅ |
## 后续项
| 项 | 状态 | 说明 |
|---|---|---|
| `/user/ai` 用户可操作偏好 | 未实现 | 当前只读 effective config,不能提升管理员权限 |
| LightRAG source/index/status 写管理 | 未实现 | 继续使用唯一 knowledge provider 与现有 facade |
| provider/skill/MCP 专用规范化表 | 未实现 | 当前原子策略存入 `ai_policies.model_policy_json`;出现独立查询、授权或生命周期需求时迁移 |
## 浏览器证据
- Pi Lab 原生抽屉:`tmp/7-71-pi-lab-browser-final.png`
- AI 管理页:`tmp/7-71-ai-admin-final.png`
- 模型写管理:`tmp/7-71-ai-admin-openhub-like-ui-final/models.png`
- Skills/MCP 写管理:`tmp/7-71-ai-admin-openhub-like-ui-final/skills-mcp.png`
- 逐用户 AI 配置:`tmp/7-71-ai-admin-openhub-like-ui-final/users.png`
- 管理页运行时摘要:会话 1、回执 3、文件补丁 1
- Pi Lab 截图可见真实 mock 回复、citation、diff、abort,且 OpenHub 默认入口仍独立存在
- `mnote-e2e` 通过 control-plane `role=admin` 进入管理员模式;浏览器验收用 dev seed 临时确保测试账号为 admin
- OpenHub 的 MCP 实际为用户工作区 `.opencode/mcp` 文件展示,并非 Admin per-user overrideMNote 采用更严格的管理员注册 + 用户启停 + facade/sandbox 模型
## 验证命令
```bash
# 静态 smoke(只读代码审计,无需启动服务器)
node scripts/task-ai-management-control-plane-static-smoke.js
MNOTE_AI_ADMIN_OUTPUT_DIR=tmp/7-71-ai-admin-openhub-like-ui-final \
node scripts/task-ai-management-browser-smoke.js
node scripts/task-pi-lab-static-smoke.js
CARGO_TARGET_DIR=/tmp/mnote-7-71-target cargo check -p mnote-web
CARGO_TARGET_DIR=/tmp/mnote-7-71-target cargo test -p control-plane ai_tool_events
CARGO_TARGET_DIR=/tmp/mnote-7-71-target cargo test -p control-plane ai_file_patches
CARGO_TARGET_DIR=/tmp/mnote-7-71-target cargo test -p control-plane migrations
CARGO_TARGET_DIR=/tmp/mnote-7-71-target cargo test -p mnote-web ai_settings
CARGO_TARGET_DIR=/tmp/mnote-7-71-target cargo test -p mnote-web page_ai_pi
CARGO_TARGET_DIR=/tmp/mnote-7-71-target cargo test -p mnote-web ai_admin
# 运行时验证(需要 mnote-web 运行于 localhost:3000
# curl -s http://localhost:3000/api/ai-settings/effective | jq '{source: .sourceOfTruth, models: [.models[].id], rootsCount: (.allowedRoots | length)}'
# curl -s http://localhost:3000/api/ai-settings/access-scopes | jq '.sourceOfTruth'
# curl -s http://localhost:3000/api/ai-admin/access-scopes | jq '.sourceOfTruth'
# curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/admin/ai
# curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/user/ai
```
@@ -449,8 +449,8 @@ assistant-ui / CopilotKit / AG-UI 进入 UI 备选评估
本稿通过后新增: 本稿通过后新增:
- `7-69-page-ai-pi-first-lab-checklist-v1.md` - `design/07-ai/done/7-69-page-ai-pi-first-lab-checklist-v1.md`
- `scripts/task*-page-ai-pi-rpc-smoke.js` - `scripts/task-pi-lab-rpc-api-smoke.js`
- `scripts/task*-page-ai-pi-lightrag-smoke.js` - `scripts/task*-page-ai-pi-lightrag-smoke.js`
- `scripts/task*-page-ai-pi-file-patch-smoke.js` - `scripts/task*-page-ai-pi-file-patch-smoke.js`
@@ -472,3 +472,38 @@ assistant-ui / CopilotKit / AG-UI 进入 UI 备选评估
Pi 不应被过早否定;它已经有足够多的 runtime 与生态基础,值得作为首选实验底座。 Pi 不应被过早否定;它已经有足够多的 runtime 与生态基础,值得作为首选实验底座。
Pi 也不应被过早宣布替换 OpenHub;MNote 需要自己补齐多用户控制面、Turso 审计、LightRAG tool、allowed roots 和 UI 产品化边界。 Pi 也不应被过早宣布替换 OpenHub;MNote 需要自己补齐多用户控制面、Turso 审计、LightRAG tool、allowed roots 和 UI 产品化边界。
## 13. Spike 执行记录(2026-07-03
本稿对应的最小可验证 spike 已落地到代码,验收记录见:
- `design/07-ai/done/7-69-page-ai-pi-first-lab-spike-record-v1.md`
- `design/07-ai/done/7-69-page-ai-pi-first-lab-checklist-v1.md`
当前完成范围:
- `/api/page-ai/pi/*``/page-ai/pi` dev-only 入口。
- Pi RPC/mock runtime manager 与 MNote 管控 sessionDir。
- 8 个 MNote-owned tool facade。
- allowed roots 越界拒绝、file version、diff summary、deny reason、receipt JSONL adapter。
- LightRAG 仍通过 `knowledge_rag` facade 暴露给 Pi,不引入第二套 RAG。
- 文件 patch 返回 watcher refresh metadata,且 `polling=false`
- MNote-native Pi Lab UI 支持 start/send/events/abort、SSE stream、tool/citation/diff/receipt 显示。
- OpenHub 仍保持默认 Page AI 主线。
当前未完成范围:
- 全局 `pi` CLI 仍未进入 PATH;但已通过 `MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh` 包装 `npx -y @earendil-works/pi-coding-agent@0.80.3` 跑通真实 `pi --mode rpc` 端到端 smoke。
- Turso/libSQL 正式 receipt 表迁移;当前仍为 `provider_neutral_jsonl_adapter_v1`
- 登录态浏览器中验证 watcher 刷新 tiptap 当前页。
- `@earendil-works/pi-web-ui` 未成为长期 UI 基座,本轮由 MNote-native debug UI 承接。
验证结果:
- `node scripts/task-pi-lab-static-smoke.js`:通过。
- `node scripts/task-pi-lab-api-endpoint-smoke.js`:通过。
- `node scripts/task-pi-lab-mock-api-smoke.js`:通过,覆盖 mock runtime、SSE、allowed roots、越界拒绝、当前页读取、当前 `.md` patch、watcher refresh metadata、LightRAG facade、citation/open-reference facade、send、abort。
- `node scripts/task-pi-lab-browser-smoke.js`:通过,覆盖 `/page-ai/pi` 独立页、Pi Lab 面板、输入框、启动/发送/清空按钮、runtime asset、status API。
- 真实 Pi RPC smoke:通过,覆盖 MNote 托管启动 `pi --mode rpc``runtimePid`、当前页读取、越界拒绝、当前 `.md` patch、send accepted、abort。
- `node scripts/task-pi-lab-rpc-api-smoke.js`:新增,覆盖 runtimeMode=rpc 下 status、runtimePid 真实子进程、start/send/abort 端点、tool-call MNote facadesallowed roots、越界拒绝、.md patch、watcher refresh、LightRAG、citation)、SSE 事件流、多会话生命周期、Pi 进程清理。需要真实 Pi CLIMNOTE_PAGE_AI_PI_BIN)与可选 API key 验证 send。
- `cd rust && cargo check -p mnote-web`:通过。
@@ -0,0 +1,562 @@
# 7-71 MNote 统一 AI 管理面板与 Pi Lab 功能接入设计 v1
状态:process
Owner07-ai / mnote-web / control-plane / Page AI
日期:2026-07-04
## 1. 结论
应该做 MNote 原生统一 AI 管理面板,但顺序不是先堆 UI,而是先把 control-plane 合同、持久化和 effective config API 做成唯一真相,再让 Pi Lab / OpenHub / LightRAG 消费同一套配置。
文件夹授权必须只有一套:当前 `/admin/access-policy``/user/access-policy` 管理的 `directory_grants` 同时就是 MNote 工作区访问授权和 AI `allowed roots` 的事实源。AI 管理面板可以把它纳入同一个信息架构,但不能再维护第二套 AI-only allowed roots。
OpenHub 的 admin 面板证明了正确产品结构:provider/model、skills、tools、MCP、目录权限、知识库、历史、用量、健康状态必须先有管理面,聊天 UI 只展示用户可用的有效配置。MNote 不能把 OpenHub admin 作为 MNote AI 的长期真相,因为 OpenHub 仍是独立默认 Page AI 主线,Pi Lab 是 MNote-native 新入口,二者后续可能任意退役。MNote AI 管理中心应落在 Turso/libSQL control-plane。
## 2. 调研依据
### 2.1 OpenHub
源码:
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/pages/AdminPage.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/components/UserSettingsDrawer.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/components/HistoryDrawer.jsx`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-frontend/src/services/api.js`
- `/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend/app/api/admin.py`
可复用的产品模型:
- 管理后台按区段管理:用户、模型配置、工具权限、技能、目录权限、知识库、用量、系统健康。
- 用户设置抽屉只显示管理员已允许的有效配置:模型、技能、MCP、目录、工具、用量。
- 权限是三层:admin global policy -> user override -> effective policy。
- 历史记录是左侧抽屉,支持新建、刷新、归档、分页/滚动。
不应直接搬的部分:
- opencode 服务管理、飞书渠道、WeKnora 配置导出。
- React/AntD 组件主链。
- OpenHub SQLite 表作为 MNote 真相层。
### 2.2 Pi / Pi Web UI
源码:
- `earendil-works-pi-web-ui-0.75.3.tgz`
- `/mnt/Data1T/tmp/pi-web-ui-shot/node_modules/@earendil-works/pi-web-ui/src/`
- `/mnt/Data1T/tmp/pi-web-ui-shot/node_modules/@earendil-works/pi-agent-core/dist/`
- `rust/crates/mnote-web/src/routes/page_ai_pi.rs`
- `rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js`
可复用的合同:
- `SessionData`: `id/title/model/thinkingLevel/messages/createdAt/lastModified`
- `SessionMetadata`: `id/title/lastModified/messageCount/usage/thinkingLevel/preview`
- `AgentEvent`: `agent_start/end``message_update/end``tool_execution_start/update/end`
- `ThinkingLevel`: `off/minimal/low/medium/high/xhigh`
- Provider/model 设置形态:SettingsDialog、ModelSelector、ProvidersModelsTab、CustomProvider。
不能照搬的部分:
- IndexedDB 是 Pi standalone browser app 的存储,不是 MNote 真相层。
- Pi 原始 `bash/read/write/edit` 仍必须禁用。
- Pi web-ui 是 Lit Web ComponentsMNote 当前 Pi Lab 保持原生 runtime,不 iframe、不让 Pi UI 接管 provider key 或工具权限。
### 2.3 MNote 当前底座
已有:
- `directory_grants`:当前文件夹授权事实。
- `ai_policies`:已有 `allowed_roots_json / model_policy_json / quota_json`,但无 HTTP 管理 API 和 UI。
- `ai_runtime_runs / ai_runtime_events`:已有跨 provider run/event journal。
- `ai_external_conversation_bindings`:已有 MNote session 与外部 provider conversation 绑定。
- `/admin/access-policy``/user/access-policy`:已有文件夹授权面板。
缺口:
- Pi Lab session 仍在内存 HashMap,重启丢失。
- Pi Lab receipt 仍在内存 + JSONL adapter,无法按用户/工作区查询。
- provider/model/skills/MCP/tools 的 MNote-native 管理 API 和 UI 不存在。
- Pi Lab UI 的历史、设置、模型选择还没有接入真实配置源。
## 3. 目标架构
```text
MNote AI 管理中心
/admin/ai admin global policy
/user/ai user effective settings
|
v
Turso/libSQL control-plane
directory_grants
ai_policies
ai_runtime_runs / ai_runtime_events
ai_external_conversation_bindings
ai_provider_configs / ai_model_catalog / ai_model_grants
ai_tool_policies / ai_skill_registry / ai_mcp_servers
ai_tool_events / ai_file_patches
|
v
MNote AI Facade APIs
effective config
allowed roots
model/provider catalog
skills/tools/MCP policy
LightRAG facade
session history / receipts
|
+--> OpenHub iframe/default Page AI
+--> Pi Lab native drawer
+--> future native agents
```
OpenHub 继续保持默认 Page AI 入口。MNote 管理中心不替换 OpenHub admin,而是成为 MNote 侧 AI 权限和配置的真相层;需要同步给 OpenHub 时,通过 provider identity sync / OpenHub admin API 做下游同步。
## 4. 管理面板信息架构
### 4.1 Admin`/admin/ai`
沿用现有 `/admin/access-policy` 的 SSR 页面方式,新增 AI 管理中心。左侧区段建议:
1. `Overview`
- AI runtime 状态、OpenHub 状态、Pi Lab 状态、LightRAG 状态、最近错误。
2. `Providers & Models`
- Omniroute/OpenAI/Anthropic/Google/local providers。
- API key/base URL/认证状态。
- 模型可见性、默认 Build/Plan/Task 模型、failover chain。
- 默认保留 `omniroute/freefirst` 作为 Pi Lab dev/default model。
3. `Access Scopes`
- 直接复用当前 `/admin/access-policy``/user/access-policy` 文件夹授权 UI 和既有授权 API,不新增 AI-only 授权目录。
- `directory_grants` 是 MNote 与 AI 共用的 allowed roots 事实源。
- `AiAccessScope.allowed_roots`、OpenHub allowedRoots、Pi Lab allowed roots 都从 `directory_grants` 派生。
- 支持 read/write、recursive、capabilities、workspace/user 维度。
- 该区段只提供授权摘要、effective scope 预览和进入现有授权页的入口;若后续内嵌编辑,也必须调用同一套 `directory_grants` command/API。
4. `Tools`
- MNote-owned tools 全局策略:allow/ask/deny。
- 默认 deny raw bash/read/write/edit。
- Pi 只显示并使用 MNote facade tools。
5. `Skills`
- MNote builtin skills、Codex/Acontext skills、OpenHub/opencode skills 的 registry。
- 全局启停,用户可在授权范围内开关。
6. `MCP`
- MCP server registry、启动命令、allowed roots、network policy、env/secret 引用。
- 默认不让 Pi 直接接管 raw MCPPi 通过 MNote facade 调用。
7. `Knowledge`
- LightRAG 是唯一默认 provider。
- 管理 source registry、index 状态、citation/open-reference 映射。
8. `Sessions & Receipts`
- Page AI / OpenHub / Pi Lab / native agent 历史。
- tool events、deny reason、diff summary、file version、citation count。
9. `Usage & Quota`
- 按用户、workspace、provider、model 统计。
- token、cost、request count、失败率。
### 4.2 User`/user/ai`
用户设置只显示 effective config
- 可用模型:来自 admin allowed models;用户只能隐藏或选择默认,不提升权限。
- 可用 skills/tools/MCP:只能在 admin 允许范围内开关或降级。
- 目录权限:查看由 `directory_grants` 派生的 allowed roots,并进入 `/user/access-policy` 请求或调整授权;不保存 AI 私有目录配置。
- 历史记录:自己的 Page AI / Pi Lab sessions。
- 用量:自己的 quota 与调用统计。
### 4.3 Pi Lab 抽屉接入
Pi Lab 顶部工具栏:
- 历史按钮:打开 Pi Lab history drawer,数据来自 MNote `/api/page-ai/pi/sessions` 或统一 `/api/page-ai/sessions?provider=pi`
- 设置按钮:打开 MNote-native user AI settings overlay;管理员可跳到 `/admin/ai`
- 模型选择:读取 effective model options,不再硬编码单个 select。
- Build/Plan:读取 agent mode policy,决定可用模型、thinkingLevel、tool policy。
- 工具/引用/diff:继续只显示 MNote facade 返回的 receipt 和 citation。
## 5. 数据合同
### 5.1 复用现有表
- `directory_grants`:MNote 与 AI 共用的唯一文件夹授权事实源,也是 allowed roots 派生源。
- `ai_policies`workspace/user 的模型、配额、工具、skills/MCP 策略汇总层;不得作为第二套 allowed roots 真相。历史字段 `allowed_roots_json` 只能视为缓存/兼容快照,写入时必须由 `directory_grants` 派生。
- `ai_runtime_runs`:统一 AI session/run 元数据。Pi Lab 不新建独立 session 真相表,先复用它。
- `ai_runtime_events`:统一 message/tool/runtime event journal。
- `ai_external_conversation_bindings`OpenHub remote conversation / future external provider binding。
- `user_ui_preferences`:前端折叠状态、默认 tab 等低风险 UI preference。
### 5.2 授权目录单一真相规则
- 不新增 `ai_allowed_roots``pi_allowed_roots``openhub_allowed_roots` 或任何同义表。
- 不允许 Pi Lab、OpenHub、AI 管理页或 session 设置单独写一份目录授权 JSON。
- 所有目录授权变更统一写入 `directory_grants`,沿用现有管理员/用户权限边界、审计和路径校验。
- AI effective config 按 user/workspace 读取 `directory_grants`,生成 `AiAccessScope.allowed_roots`
- Pi Lab 创建 session/run 时记录 effective scope 快照,但每次工具执行仍按当前 `directory_grants` 重新校验。
- OpenHub `allowedRoots` 是 MNote 下发的下游投影,不能反向成为授权真相。
- `ai_policies.allowed_roots_json` 只能作为不可独立编辑的兼容快照、迁移输入或缓存;满足迁移条件后应删除或改名,避免误用和双写。
### 5.3 新增或规范化表
第一阶段优先最小新增:
```sql
ai_tool_events(
id,
user_id,
workspace_id,
session_id,
run_id,
provider,
provider_session_id,
tool_name,
allowed,
deny_reason,
root_uri,
page_path,
normalized_file_path,
diff_summary,
citation_count,
before_file_version,
after_file_version,
payload_json,
created_at,
deleted_at
)
```
```sql
ai_file_patches(
id,
user_id,
workspace_id,
session_id,
run_id,
tool_event_id,
root_uri,
relative_path,
before_file_version,
after_file_version,
patch_summary_json,
created_at
)
```
第二阶段再规范化:
- `ai_provider_configs`
- `ai_model_catalog`
- `ai_model_grants`
- `ai_tool_policies`
- `ai_skill_registry`
- `ai_skill_grants`
- `ai_mcp_servers`
- `ai_mcp_grants`
- `ai_usage_logs`
- `ai_model_failover_chains`
说明:不要急着新增 `ai_provider_sessions`。MNote 已有 `ai_runtime_runs / ai_runtime_events`Pi Lab history 可以先落这里;只有当 provider 原生 thread 与 MNote run/event journal 差异太大时,再加 provider-specific session detail 表。
## 6. API 合同
### 6.1 管理 API
```text
GET /api/ai-admin/overview
GET /api/ai-admin/providers
PUT /api/ai-admin/providers/{provider_id}
GET /api/ai-admin/models
PUT /api/ai-admin/models/{provider_id}/{model_id}/policy
GET /api/ai-admin/access-scopes
GET /api/ai-admin/tools
PUT /api/ai-admin/tools/{tool_name}/policy
GET /api/ai-admin/skills
PUT /api/ai-admin/skills/{skill_id}/policy
GET /api/ai-admin/mcp
PUT /api/ai-admin/mcp/{server_id}/policy
GET /api/ai-admin/knowledge
GET /api/ai-admin/sessions
GET /api/ai-admin/receipts
GET /api/ai-admin/usage
```
### 6.2 用户有效配置 API
```text
GET /api/ai-settings/effective
GET /api/ai-settings/models
PUT /api/ai-settings/models/{provider_id}/{model_id}
GET /api/ai-settings/tools
PUT /api/ai-settings/tools/{tool_name}
GET /api/ai-settings/skills
PUT /api/ai-settings/skills/{skill_id}
GET /api/ai-settings/mcp
GET /api/ai-settings/access-scopes
GET /api/ai-settings/usage
```
`GET /api/ai-admin/access-scopes``GET /api/ai-settings/access-scopes` 只读返回由 `directory_grants` 计算的 effective scope,并返回现有授权管理页入口。授权写入继续使用现有 access-policy / `directory_grants` command/API;不在 AI admin route 中定义第二套 grants 写语义。
### 6.3 Pi Lab history API
可以做成 Pi-specific facade,但底层写 `ai_runtime_runs/events`
```text
GET /api/page-ai/pi/sessions
GET /api/page-ai/pi/sessions/{session_id}
PUT /api/page-ai/pi/sessions/{session_id}/title
DELETE /api/page-ai/pi/sessions/{session_id}
GET /api/page-ai/pi/sessions/{session_id}/events
```
响应参考 Pi `SessionMetadata`
```json
{
"id": "pi_lab_...",
"title": "当前页解释",
"model": "omniroute/freefirst",
"thinkingLevel": "medium",
"lastModified": "2026-07-04T14:20:00Z",
"messageCount": 8,
"usage": {"input": 1200, "output": 400, "totalTokens": 1600},
"preview": "帮我解释当前页面的逻辑..."
}
```
## 7. 实施顺序
### P0:合同与只读聚合
- 新增设计和 smoke baseline。
- 增加 `/api/ai-settings/effective` 只读 API。
- 聚合已有 `directory_grants``ai_policies`、agent descriptors、Pi defaults。
- 明确 `effective.allowedRoots` 只从 `directory_grants` 派生;若 `ai_policies.allowed_roots_json` 存在,只作为兼容只读快照或迁移源,不参与新增写入。
- Pi Lab model selector 改为消费 effective models,但仍保留 `omniroute/freefirst` fallback。
验收:
- OpenHub 默认入口不变。
- Pi Lab 打开后能显示来自 MNote effective config 的模型、allowed roots、tool policy。
### P1Pi Lab history 入库
- Pi Lab start/send/end 写 `ai_runtime_runs / ai_runtime_events`
- 历史 drawer 读 MNote DB,不读 IndexedDB、不读内存 HashMap。
- 现有 JSONL receipt 保留为 debug fallback。
验收:
- 重启 mnote-web 后 Pi Lab 历史仍可见。
- 同一 user/workspace 隔离生效。
### P2Receipt 与 patch 入库
- 新增 `ai_tool_events / ai_file_patches`
- `mnote.local_file.read/patch`、LightRAG query、reference.open、tool_receipt.write 全部写入。
- Pi Lab receipt rail 改读 DB event stream。
验收:
- allowed roots deny、diff summary、file version、citation count 可在管理面板查询。
- patch 后 watcher refresh 仍不新增轮询。
### P3MNote AI 管理中心首版
- `/admin/ai` 先做四个可用 tabOverview、Providers & Models、Access Scopes、Sessions & Receipts。
- `/user/ai` 做 Models、Access、History、Usage。
- 复用 `/admin/access-policy` / `/user/access-policy` 逻辑,不拆第二套授权事实;AI 面板里的 Access Scopes 本质上是该授权页面的 AI 视角入口。
验收:
- 管理员可设置 Pi Lab 默认模型和可见模型。
- 用户只能选择管理员允许的模型。
- 文件夹授权仍同步 OpenHub,不破坏 OpenHub。
### P4Skills / MCP / Tools / LightRAG 管理
- Skills registry 与 grants。
- MCP registry 与 sandbox policy。
- MNote-owned tools allow/ask/deny。
- LightRAG source/index/status 管理。
验收:
- Pi Lab settings 能显示 skills/MCP/tools effective 状态。
- Pi 仍不能直接启用 raw bash/read/write/edit。
- LightRAG 仍是唯一默认 knowledge provider。
## 8. 关键决策
1. **MNote 管理中心是必需的**,但第一步是 control-plane + effective API,不是先做完整 UI。
2. **OpenHub admin 只作为参考和下游同步对象**,不作为 MNote AI 真相层。
3. **文件夹授权只保留一套**`directory_grants` 同时服务 MNote 工作区访问、OpenHub `allowedRoots` 和 Pi Lab `AiAccessScope.allowed_roots`;AI 管理页只提供同源视图和现有授权入口。
4. **Pi IndexedDB schema 可参考,不能作为 MNote 持久化方案**
5. **Pi Lab history 先复用 `ai_runtime_runs/events`**,避免新增重复 session 表。
6. **receipt/patch 必须入 Turso/libSQL**JSONL 只保留 debug/crash recovery。
7. **skills/MCP/tools 先管权限,再接 UI**;否则会把未受控工具暴露给 Pi。
8. **secrets 不进前端、不硬编码**provider API key 应只存 secret reference/env key,后续再接正式 secret store。
## 9. 风险
- 直接把 OpenHub admin 嵌进 MNote 会形成第二个 iframe 管理系统,和 Pi Lab 原生化目标冲突。
- 先做 Pi UI settings 会把配置写进临时 DOM/内存,后续仍要重接 Turso。
- 如果不先统一 historyPi Lab 与 OpenHub 会话会分裂,用户无法判断哪个入口产生了修改。
- MCP 管理若过早放开,容易绕过 AiAccessScope;必须默认只注册、只显示,不默认启用。
- Model/provider secret 管理不能复用 Codex auth 作为产品实现;Codex auth 只可作为 dev smoke 输入。
## 10. 下一步最小可执行设计稿
建议下一张执行稿拆成:
`7-72-page-ai-pi-lab-history-and-effective-config-control-plane-v1`
范围只做:
- `/api/ai-settings/effective`
- Pi Lab model options 读取 effective config
- Pi Lab session 写入 `ai_runtime_runs/events`
- Pi Lab history drawer 读 DB
- receipt 仍保留 JSONL,但设计 migration 到 `ai_tool_events`
- smoke 覆盖重启后历史存在、用户隔离、OpenHub 默认入口不变
做完 7-72 后,再做 `/admin/ai` 首版 UI。这样不会在 UI 上先行制造第二套事实源。
---
## 11. 实施进度(2026-07-04
### P0: 合同与只读聚合 — 进度
| 项 | 状态 | 证据 |
|---|---|---|
| 新增设计和 smoke baseline | ✅ 已完成 | `design/07-ai/process/7-71-*` 设计文档、`scripts/task-ai-management-control-plane-static-smoke.js` 静态 smoke27/27 通过) |
| `/api/ai-settings/effective` 只读 API | ✅ 已实现 | `routes/ai_settings.rs::effective_settings` — GET-only,读 `directory_grants``ai_policies.model_policy_json/quota_json` |
| `/api/ai-settings/access-scopes` 用户只读 API | ✅ 已实现 | `routes/ai_settings.rs::user_access_scopes` — GET-only |
| `/api/ai-admin/access-scopes` 管理员只读 API | ✅ 已实现 | `routes/ai_settings.rs::admin_access_scopes` — GET-only,管理员鉴权 |
| 聚合 `directory_grants``ai_policies`、agent descriptors、Pi defaults | ✅ 已实现 | effective 响应聚合 providers、models、tool_catalog、lightrag_provider、access_policy_links |
| `effective.allowedRoots` 只从 `directory_grants` 派生 | ✅ 已验证 | `load_active_directory_grants` 读取 `list_directory_grants_for_actor``ai_policies.allowed_roots_json` 不被读取 |
| Pi Lab model selector 消费 effective models | ✅ 已实现 | `effective_settings` 响应含 models、default_modelPi Lab 前端消费 |
| 静态 smoke 覆盖:路由注册、source of truth、无写操作 | ✅ 27/27 通过 | `scripts/task-ai-management-control-plane-static-smoke.js` |
### P1: Pi Lab history 入库 — 进度
| 项 | 状态 | 证据 |
|---|---|---|
| Pi Lab start/send/end 写 `ai_runtime_runs / ai_runtime_events` | ✅ 已实现 | `page_ai_pi.rs::persist_upsert_run` / `persist_append_event` |
| 历史 drawer 读 MNote DB | ✅ 已实现 | `list_sessions``control_plane.list_ai_runtime_runs``get_session_history` / `get_session_events` 分别读 run 和 events |
| 现有 JSONL receipt 保留为 debug fallback | ✅ 已保留 | `PI_LAB_RECEIPT_STORE` JSONL 静态存储仍在 |
| Pi Lab session 路由注册 | ✅ 已验证 | `GET /api/page-ai/pi/sessions``GET .../{session_id}``GET .../{session_id}/events` |
| Pi Lab session 按 pi_lab/pi profile/acp_runtime 过滤 | ✅ 已验证 | `list_sessions` 过滤 `r.profile == PI_LAB_PROFILE && r.acp_runtime == PI_LAB_ACP_RUNTIME` |
### P2: Receipt 与 patch 入库 — 已完成
| 项 | 状态 | 证据 |
|---|---|---|
| `ai_tool_events / ai_file_patches` migration | ✅ | `control-plane/migrations/009-ai-tool-events-file-patches.sql` |
| SQLite 与 Turso/libSQL store | ✅ | `append/list_ai_tool_event``append/list_ai_file_patch` |
| Pi tool receipt 入 control-plane | ✅ | `page_ai_pi.rs::write_receipt` |
| patch 关联写入 file patch journal | ✅ | receipt id 作为 `tool_event_id` |
| JSONL 降级为 debug fallback | ✅ | 仅 control-plane 写失败后追加 |
| 管理 API 查询 receipts / patches | ✅ | `/api/ai-settings/receipts``/api/ai-admin/receipts` |
### P3: MNote AI 管理中心首版 — 管理员写管理完成
| 项 | 状态 | 证据 |
|---|---|---|
| `/admin/ai` 路由与入口 | ✅ 已实现 | `gateway.rs::admin_ai_entry`,鉴权后跳转,管理员模式 |
| `/user/ai` 路由与入口 | ✅ 已实现 | `gateway.rs::user_ai_entry`,鉴权后跳转,用户模式 |
| 账户菜单独立 AI 管理入口 | ✅ 已实现 | `sidebar-workspace-runtime.js` `mnote-account-ai-management`,按角色跳 `/admin/ai``/user/ai` |
| AI 管理页面 SSR | ✅ 已实现 | `ssr/pages/ai_admin.rs::AiManagementPage` — 对齐 OpenHub Admin 左侧分页、白色 Card 壳、表格、抽屉与分组权限面板;管理 Overview、模型、服务、用量、工具、Skills/MCP、目录、知识库、会话与监控 |
| Access Scopes 只读展示 | ✅ 已验证 | 页面脚本只 GET access-scopes 数据,无 POST/PUT/DELETE,无写表单 |
| Sessions & Receipts 区域 | ✅ 已接真实数据 | 展示 Pi session、tool receipt、deny reason、diff summary、patch 数量 |
| `/admin/ai` 管理写操作(provider/model 编辑等) | ✅ 已实现 | `GET/PUT /api/ai-admin/settings`;模型配置浏览器保存与刷新持久化通过 |
| `/user/ai` 用户设置 | ❌ 未实现 | 当前 `/user/ai` 复用 `AiManagementPage` 用户模式,仅有只读视图;逐用户模型/工具/Skill/MCP override 已在管理员用户抽屉完成 |
### P4: Skills / MCP / Tools / LightRAG 管理 — 部分完成
- Skills、MCP 与工具策略已通过原子管理 API 和 SSR 管理 UI 写入 `ai_policies.model_policy_json`
- MCP 强制 `facadeOnly=true``sandbox=true`secret 仅接受 `env://` / `secret://`
- MNote-owned tools 已支持 allow/ask/deny
- LightRAG source/index/status 管理 UI 未实现
- provider/skill/MCP 专用规范化表未实现;出现独立查询、授权或生命周期需求时再迁移
### OpenHub / Pi Lab 独立入口状态
| 项 | 状态 |
|---|---|
| OpenHub `/page-ai/openhub/ai` 未删除 | ✅ |
| OpenHub `/page-ai/openhub/admin` 未删除 | ✅ |
| OpenHub status API 未删除 | ✅ |
| Pi Lab shell `/page-ai/pi` 未删除 | ✅ |
| Pi Lab start/send/abort/events 路由未删除 | ✅ |
### 验证命令占位
```bash
# 静态 smoke(只读代码审计)
node scripts/task-ai-management-control-plane-static-smoke.js
# 运行时 smoke(需要 mnote-web 运行中)
# curl -s http://localhost:3000/api/ai-settings/effective | jq '.sourceOfTruth'
# curl -s http://localhost:3000/api/ai-settings/access-scopes | jq '.sourceOfTruth'
# curl -s http://localhost:3000/api/ai-admin/access-scopes | jq '.sourceOfTruth'
# curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/admin/ai
# curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/user/ai
# curl -s http://localhost:3000/api/page-ai/pi/sessions | jq '.schema'
# curl -s http://localhost:3000/api/ai-settings/receipts | jq '{receiptCount, patchCount}'
```
### 运行时与浏览器证据
- 隔离运行端口:`127.0.0.1:33171`
- `task-pi-lab-mock-api-smoke.js`:通过
- `task-pi-lab-browser-smoke.js`:通过
- Pi Lab 截图:`tmp/7-71-pi-lab-browser-final.png`
- AI 管理页截图:`tmp/7-71-ai-admin-final.png`
- 模型写管理截图:`tmp/7-71-ai-admin-openhub-like-ui-final/models.png`
- Skills/MCP 写管理截图:`tmp/7-71-ai-admin-openhub-like-ui-final/skills-mcp.png`
- OpenHub-like 逐用户配置抽屉截图:`tmp/7-71-ai-admin-openhub-like-ui-final/users.png`
- 管理页实测摘要:会话 1、回执 3、文件补丁 1
- OpenHub 仍为默认 Page AIPi Lab 继续使用独立按钮、独立抽屉与独立 session
- `mnote-e2e` 已通过 control-plane `role=admin` 验证管理员访问和写入;浏览器 smoke 使用 dev seed 仅为临时验收账号准备,不写第二套授权目录
### P4: Skills / MCP / Tools 管理 — 验收结果
| 项 | 状态 | 证据 |
|---|---|---|
| admin 原子策略 GET/PUT | ✅ 通过 | `/api/ai-admin/settings`,统一保存模型、工具、Skills、MCP |
| 浏览器保存与刷新持久化 | ✅ 通过 | `scripts/task-ai-management-browser-smoke.js` |
| secretRef-only 检查 | ✅ 通过 (3 项) | Section 9: 无硬编码 api_key/secret,仅 env 引用 |
| allowed roots 不可写检查 | ✅ 通过 (4 项) | Section 10: 无写路由、无写函数、无写表单 |
| MCP facade-only 检查 | ✅ 通过 (3 项) | Section 11: Pi 仅暴露 mnote. 工具,MCP 经 facade 管控 |
### P5: OpenHub 式逐用户 AI 配置 — 已完成
- OpenHub Admin 的真实用户管理入口为用户表格 + 模型/工具/Skill/目录权限操作;MCP 仅展示用户工作区 `.opencode/mcp` 文件,不存在 Admin per-user MCP 策略。
- MNote 新增用户列表与逐用户 AI 策略 API,用户 override 继续落 `ai_policies(user_id, workspace_id=NULL)`
- 当前 `ai_policies` 没有系统全局行,因此暂以 `MNOTE_ADMIN_USER_IDS` 首个用户作为全局策略 owner;迁移到规范化 policy 表时移除此兼容约定。
- 本轮已把 MNote `/admin/ai` 用户配置抽屉改成 OpenHub-like 体验:模型按 provider 分组、工具按 risk level 分组、Skill/MCP 使用启停开关、目录权限只读并跳转统一授权管理页。
- effective 合并规则:
- 模型只能从管理员 allowed models 中选择;
- 工具只能降权,不能从 deny/ask 提升到 allow
- Skill/MCP 只能关闭或启用全局已允许项;
- MCP 始终保持 facade-only + sandbox
- allowed roots 完全不参与该合并,继续只读 `directory_grants`
- 浏览器截图:`tmp/7-71-ai-admin-openhub-like-ui-final/users.png`
- 浏览器 smoke 同时以普通用户 `ai-user` 登录,确认 effective API 不返回被禁用模型和 Skill。
### 本轮 smoke 结果
静态 smoke:**47 通过,0 失败**。浏览器 smoke 验证模型、工具、Skills/MCP 保存、OpenHub Admin 独立入口保留、OpenHub-like 用户抽屉、逐用户 override、刷新持久化及 effective 投影全部通过。
```
$ node scripts/task-ai-management-control-plane-static-smoke.js
通过: 47 失败: 0 跳过: 0
$ MNOTE_AI_ADMIN_OUTPUT_DIR=tmp/7-71-ai-admin-openhub-like-ui-final \
node scripts/task-ai-management-browser-smoke.js
ok: true
```
### 后续范围
- `/user/ai` 用户模型/Skills/MCP 偏好
- LightRAG source/index/status 写管理
- provider/skill/MCP 专用规范化表迁移
+232 -374
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
-- P2: AI tool events (receipts) and file patches for per-user/per-session query.
-- See design/07-ai/process/7-71-unified-ai-management-control-plane-and-pi-lab-integration-v1.md
CREATE TABLE IF NOT EXISTS ai_tool_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT,
session_id TEXT NOT NULL,
run_id TEXT,
provider TEXT NOT NULL,
provider_session_id TEXT,
tool_name TEXT NOT NULL,
allowed INTEGER NOT NULL DEFAULT 1,
deny_reason TEXT,
root_uri TEXT NOT NULL,
page_path TEXT,
normalized_file_path TEXT,
diff_summary TEXT,
citation_count INTEGER NOT NULL DEFAULT 0,
before_file_version TEXT,
after_file_version TEXT,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_ai_tool_events_user_session
ON ai_tool_events(user_id, session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_tool_events_user_run
ON ai_tool_events(user_id, run_id, created_at);
CREATE TABLE IF NOT EXISTS ai_file_patches (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT,
session_id TEXT NOT NULL,
run_id TEXT,
tool_event_id TEXT NOT NULL REFERENCES ai_tool_events(id) ON DELETE CASCADE,
root_uri TEXT NOT NULL DEFAULT '',
relative_path TEXT NOT NULL,
before_file_version TEXT,
after_file_version TEXT,
patch_summary_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_user_session
ON ai_file_patches(user_id, session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_user_run
ON ai_file_patches(user_id, run_id, created_at);
CREATE INDEX IF NOT EXISTS idx_ai_file_patches_tool_event
ON ai_file_patches(tool_event_id);
@@ -33,6 +33,8 @@ const TABLE_ORDER: &[&str] = &[
"ai_agent_profiles", "ai_agent_profiles",
"ai_agent_profile_grants", "ai_agent_profile_grants",
"ai_external_conversation_bindings", "ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
]; ];
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -78,6 +80,16 @@ impl ControlPlaneStore for StoreHandle {
} }
} }
fn list_users(
&self,
limit: usize,
) -> Result<Vec<control_plane::UserRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_users(limit),
StoreHandle::Turso(store) => store.list_users(limit),
}
}
fn create_password_identity( fn create_password_identity(
&self, &self,
input: control_plane::CreatePasswordIdentityInput, input: control_plane::CreatePasswordIdentityInput,
@@ -702,6 +714,50 @@ impl ControlPlaneStore for StoreHandle {
} }
} }
} }
fn append_ai_tool_event(
&self,
input: control_plane::AppendAiToolEventInput,
) -> Result<control_plane::AiToolEventRecord, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.append_ai_tool_event(input),
StoreHandle::Turso(store) => store.append_ai_tool_event(input),
}
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<control_plane::AiToolEventRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_ai_tool_events(user_id, session_id, limit),
StoreHandle::Turso(store) => store.list_ai_tool_events(user_id, session_id, limit),
}
}
fn append_ai_file_patch(
&self,
input: control_plane::AppendAiFilePatchInput,
) -> Result<control_plane::AiFilePatchRecord, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.append_ai_file_patch(input),
StoreHandle::Turso(store) => store.append_ai_file_patch(input),
}
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<control_plane::AiFilePatchRecord>, ControlPlaneError> {
match self {
StoreHandle::Sqlite(store) => store.list_ai_file_patches(user_id, session_id, limit),
StoreHandle::Turso(store) => store.list_ai_file_patches(user_id, session_id, limit),
}
}
} }
#[derive(Debug)] #[derive(Debug)]
@@ -39,6 +39,10 @@ const MIGRATIONS: &[(&str, &str)] = &[
"v8-ai-external-conversation-bindings", "v8-ai-external-conversation-bindings",
include_str!("../migrations/008-ai-external-conversation-bindings.sql"), include_str!("../migrations/008-ai-external-conversation-bindings.sql"),
), ),
(
"v9-ai-tool-events-file-patches",
include_str!("../migrations/009-ai-tool-events-file-patches.sql"),
),
]; ];
/// Create the `_migrations` meta-table if it does not exist. /// Create the `_migrations` meta-table if it does not exist.
@@ -169,6 +173,8 @@ mod tests {
"ai_agent_profiles", "ai_agent_profiles",
"ai_agent_profile_grants", "ai_agent_profile_grants",
"ai_external_conversation_bindings", "ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
] { ] {
assert!(table_exists(&conn, table), "table {table} should exist"); assert!(table_exists(&conn, table), "table {table} should exist");
} }
@@ -224,6 +230,8 @@ mod tests {
"ai_agent_profiles", "ai_agent_profiles",
"ai_agent_profile_grants", "ai_agent_profile_grants",
"ai_external_conversation_bindings", "ai_external_conversation_bindings",
"ai_tool_events",
"ai_file_patches",
]; ];
for table in tables { for table in tables {
let exists = rt.block_on(async { libsql_table_exists(&conn, table).await }); let exists = rt.block_on(async { libsql_table_exists(&conn, table).await });
+82
View File
@@ -534,3 +534,85 @@ pub struct AppendAuditInput {
pub target_id: Option<EntityId>, pub target_id: Option<EntityId>,
pub metadata_json: String, pub metadata_json: String,
} }
// ---------------------------------------------------------------------------
// P2: AI tool events (receipts) and file patches — 7-71
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiToolEventRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub provider: String,
pub provider_session_id: Option<String>,
pub tool_name: String,
pub allowed: bool,
pub deny_reason: Option<String>,
pub root_uri: String,
pub page_path: Option<String>,
pub normalized_file_path: Option<String>,
pub diff_summary: Option<String>,
pub citation_count: i64,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub payload_json: String,
pub created_at: Timestamp,
pub deleted_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppendAiToolEventInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub provider: String,
pub provider_session_id: Option<String>,
pub tool_name: String,
pub allowed: bool,
pub deny_reason: Option<String>,
pub root_uri: String,
pub page_path: Option<String>,
pub normalized_file_path: Option<String>,
pub diff_summary: Option<String>,
pub citation_count: i64,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub payload_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiFilePatchRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub tool_event_id: EntityId,
pub root_uri: String,
pub relative_path: String,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub patch_summary_json: String,
pub created_at: Timestamp,
pub deleted_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppendAiFilePatchInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub session_id: EntityId,
pub run_id: Option<EntityId>,
pub tool_event_id: EntityId,
pub root_uri: String,
pub relative_path: String,
pub before_file_version: Option<String>,
pub after_file_version: Option<String>,
pub patch_summary_json: String,
}
+395 -13
View File
@@ -9,17 +9,17 @@ use crate::error::ControlPlaneError;
use crate::migrations; use crate::migrations;
use crate::model::{ use crate::model::{
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord, password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord, AiAgentProfileRecord, AiExternalConversationBindingRecord, AiFilePatchRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiToolEventRecord,
AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, AppendAuditInput,
AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UpsertWorkspaceInput,
WorkspaceRecord, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
}; };
use crate::store::ControlPlaneStore; use crate::store::ControlPlaneStore;
@@ -469,6 +469,49 @@ fn row_to_ai_external_conversation_binding(
}) })
} }
fn row_to_ai_tool_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiToolEventRecord> {
Ok(AiToolEventRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
provider: row.get(5)?,
provider_session_id: row.get(6)?,
tool_name: row.get(7)?,
allowed: row.get(8)?,
deny_reason: row.get(9)?,
root_uri: row.get(10)?,
page_path: row.get(11)?,
normalized_file_path: row.get(12)?,
diff_summary: row.get(13)?,
citation_count: row.get(14)?,
before_file_version: row.get(15)?,
after_file_version: row.get(16)?,
payload_json: row.get(17)?,
created_at: row.get(18)?,
deleted_at: row.get(19)?,
})
}
fn row_to_ai_file_patch(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiFilePatchRecord> {
Ok(AiFilePatchRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
tool_event_id: row.get(5)?,
root_uri: row.get(6)?,
relative_path: row.get(7)?,
before_file_version: row.get(8)?,
after_file_version: row.get(9)?,
patch_summary_json: row.get(10)?,
created_at: row.get(11)?,
deleted_at: row.get(12)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String { fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json) let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok() .ok()
@@ -557,7 +600,9 @@ fn list_ai_agent_profile_access_rows(
} }
impl SqliteControlPlaneStore { impl SqliteControlPlaneStore {
fn lock_conn(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, ControlPlaneError> { fn lock_conn(
&self,
) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, ControlPlaneError> {
self.conn self.conn
.lock() .lock()
.map_err(|e| ControlPlaneError::Storage(format!("sqlite lock poisoned: {e}"))) .map_err(|e| ControlPlaneError::Storage(format!("sqlite lock poisoned: {e}")))
@@ -639,6 +684,22 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
}) })
} }
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let limit = limit.min(1000).max(1);
let mut stmt = conn.prepare(
"SELECT id, email, username, display_name, role, status, created_at, updated_at, revision
FROM users
ORDER BY created_at ASC, id ASC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_user)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn create_password_identity( fn create_password_identity(
&self, &self,
input: CreatePasswordIdentityInput, input: CreatePasswordIdentityInput,
@@ -950,7 +1011,10 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
let name = input.name.trim().to_string(); let name = input.name.trim().to_string();
let root_uri = input.root_uri.trim().to_string(); let root_uri = input.root_uri.trim().to_string();
let root_path = input.root_path.trim().to_string(); let root_path = input.root_path.trim().to_string();
if owner_user_id.is_empty() || name.is_empty() || root_uri.is_empty() || root_path.is_empty() if owner_user_id.is_empty()
|| name.is_empty()
|| root_uri.is_empty()
|| root_path.is_empty()
{ {
return Err(ControlPlaneError::InvalidInput( return Err(ControlPlaneError::InvalidInput(
"workspace owner/name/root 不能为空".to_string(), "workspace owner/name/root 不能为空".to_string(),
@@ -2964,6 +3028,154 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
)?; )?;
Ok(changed) Ok(changed)
} }
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError> {
if input.user_id.trim().is_empty() || input.session_id.trim().is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai tool event user_id/session_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.lock_conn()?;
let record = AiToolEventRecord {
id: input.id.unwrap_or_else(|| new_id("ate")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
provider: input.provider,
provider_session_id: input.provider_session_id,
tool_name: input.tool_name,
allowed: input.allowed,
deny_reason: input.deny_reason,
root_uri: input.root_uri,
page_path: input.page_path,
normalized_file_path: input.normalized_file_path,
diff_summary: input.diff_summary,
citation_count: input.citation_count,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
payload_json: input.payload_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_tool_events (id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.provider, record.provider_session_id, record.tool_name, record.allowed, record.deny_reason,
record.root_uri, record.page_path, record.normalized_file_path, record.diff_summary, record.citation_count,
record.before_file_version, record.after_file_version, record.payload_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at, deleted_at
FROM ai_tool_events
WHERE user_id = ?1 AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_tool_event,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError> {
if input.user_id.trim().is_empty()
|| input.session_id.trim().is_empty()
|| input.tool_event_id.trim().is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"ai file patch user_id/session_id/tool_event_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.patch_summary_json)?;
let conn = self.lock_conn()?;
let record = AiFilePatchRecord {
id: input.id.unwrap_or_else(|| new_id("afp")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
tool_event_id: input.tool_event_id,
root_uri: input.root_uri,
relative_path: input.relative_path,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
patch_summary_json: input.patch_summary_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_file_patches (id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.tool_event_id, record.root_uri, record.relative_path,
record.before_file_version, record.after_file_version, record.patch_summary_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at, deleted_at
FROM ai_file_patches
WHERE user_id = ?1
AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_file_patch,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
} }
fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str { fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str {
@@ -3050,6 +3262,48 @@ mod tests {
assert_eq!(updated.revision, 2); assert_eq!(updated.revision, 2);
} }
#[test]
fn list_users_returns_stable_ordering() {
let store = store();
// Create 3 users in known order
let u1 = create_user(&store, "bravo");
let u2 = create_user(&store, "alpha");
let u3 = create_user(&store, "charlie");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 3, "should return all three users");
// Order must be by created_at ASC, id ASC
// u1 (bravo) created first, u2 (alpha) second, u3 (charlie) third
assert_eq!(users[0].id, u1.id, "first created should be first");
assert_eq!(users[1].id, u2.id, "second created should be second");
assert_eq!(users[2].id, u3.id, "third created should be third");
}
#[test]
fn list_users_respects_limit() {
let store = store();
for i in 0..5usize {
create_user(&store, &format!("user{i}"));
}
let users = store.list_users(3).expect("list users with limit");
assert_eq!(users.len(), 3, "should respect limit=3");
}
#[test]
fn list_users_excludes_password_data() {
let store = store();
create_user(&store, "nopassword");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 1);
// UserRecord has no password_hash field — this is a compile-time
// guarantee that list_users cannot leak password data.
assert!(users[0].email.is_some());
}
#[test] #[test]
fn ensure_default_workspace_creates_owner_grant() { fn ensure_default_workspace_creates_owner_grant() {
let store = store(); let store = store();
@@ -4224,4 +4478,132 @@ mod tests {
.expect("lookup failed session") .expect("lookup failed session")
.is_none()); .is_none());
} }
#[test]
fn ai_tool_events_append_and_list() {
let store = store();
create_user(&store, "tool_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_1".to_string()),
provider: "doubao-web".to_string(),
provider_session_id: Some("ps_1".to_string()),
tool_name: "mnote.local_file.read".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("page.md".to_string()),
normalized_file_path: Some("/tmp/page.md".to_string()),
diff_summary: Some("read file".to_string()),
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append tool event");
assert_eq!(event.tool_name, "mnote.local_file.read");
assert!(event.allowed);
assert_eq!(event.citation_count, 0);
// Append a denied event
store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_2".to_string()),
provider: "reasonix".to_string(),
provider_session_id: None,
tool_name: "fs.write".to_string(),
allowed: false,
deny_reason: Some("path not in allowed roots".to_string()),
root_uri: "file:///etc".to_string(),
page_path: None,
normalized_file_path: Some("/etc/passwd".to_string()),
diff_summary: None,
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append denied event");
let events = store
.list_ai_tool_events("tool_user", Some("sess_1"), 10)
.expect("list events");
assert_eq!(events.len(), 2);
assert_eq!(events[0].tool_name, "fs.write");
assert!(!events[0].allowed);
let all_events = store
.list_ai_tool_events("tool_user", None, 10)
.expect("list all events");
assert_eq!(all_events.len(), 2);
}
#[test]
fn ai_file_patches_append_and_list() {
let store = store();
create_user(&store, "patch_user");
// First create a tool event to reference
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: Some("ate_patch_ref".to_string()),
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
provider: "openclaw".to_string(),
provider_session_id: None,
tool_name: "mnote.local_file.patch".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("test.md".to_string()),
normalized_file_path: Some("/tmp/test.md".to_string()),
diff_summary: Some("edit file".to_string()),
citation_count: 0,
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
payload_json: "{}".to_string(),
})
.expect("append reference tool event");
let patch = store
.append_ai_file_patch(AppendAiFilePatchInput {
id: None,
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
tool_event_id: event.id.clone(),
root_uri: "file:///tmp".to_string(),
relative_path: "test.md".to_string(),
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
patch_summary_json: r#"{"insertions":5,"deletions":2}"#.to_string(),
})
.expect("append file patch");
assert_eq!(patch.tool_event_id, event.id);
assert_eq!(patch.relative_path, "test.md");
assert!(patch.deleted_at.is_none());
let patches = store
.list_ai_file_patches("patch_user", Some("sess_p1"), 10)
.expect("list patches");
assert_eq!(patches.len(), 1);
assert_eq!(patches[0].tool_event_id, event.id);
let all_patches = store
.list_ai_file_patches("patch_user", None, 10)
.expect("list all patches");
assert_eq!(all_patches.len(), 1);
}
} }
+47 -11
View File
@@ -2,22 +2,26 @@
use crate::error::ControlPlaneError; use crate::error::ControlPlaneError;
use crate::model::{ use crate::model::{
AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiPolicyRecord, AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiFilePatchRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord,
AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AiToolEventRecord, AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput,
AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, SidebarShortcutRecord, SyncStateRecord, UpsertAiExternalConversationBindingInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
WorkspaceRecord, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
}; };
pub trait ControlPlaneStore: Send + Sync { pub trait ControlPlaneStore: Send + Sync {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError>; fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError>;
/// List users with stable ordering by created_at (ASC), id (ASC) as tiebreaker.
/// Returns at most `limit` records. Passwords are never included.
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError>;
fn create_password_identity( fn create_password_identity(
&self, &self,
input: CreatePasswordIdentityInput, input: CreatePasswordIdentityInput,
@@ -308,4 +312,36 @@ pub trait ControlPlaneStore: Send + Sync {
session_id: &str, session_id: &str,
workspace_id: Option<&str>, workspace_id: Option<&str>,
) -> Result<usize, ControlPlaneError>; ) -> Result<usize, ControlPlaneError>;
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError>;
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError>;
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError>;
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError>;
} }
+386 -15
View File
@@ -15,17 +15,17 @@ use crate::error::ControlPlaneError;
use crate::migrations; use crate::migrations;
use crate::model::{ use crate::model::{
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord, password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord, AiAgentProfileRecord, AiExternalConversationBindingRecord, AiFilePatchRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiRuntimeEventRecord, AiRuntimeJournalEventRecord, AiRuntimeRunRecord, AiToolEventRecord,
AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord, AuthSessionRecord, AppendAiFilePatchInput, AppendAiRuntimeEventInput, AppendAiToolEventInput, AppendAuditInput,
AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup, CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput, SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertUserUiPreferenceInput, UpsertWorkspaceInput, UserRecord, UserUiPreferenceRecord, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UpsertWorkspaceInput,
WorkspaceRecord, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
}; };
use crate::store::ControlPlaneStore; use crate::store::ControlPlaneStore;
@@ -486,9 +486,9 @@ impl TursoControlPlaneStore {
} }
fn lock_conn(&self) -> Result<MutexGuard<'_, TursoConnection>, ControlPlaneError> { fn lock_conn(&self) -> Result<MutexGuard<'_, TursoConnection>, ControlPlaneError> {
self.conn self.conn.lock().map_err(|error| {
.lock() ControlPlaneError::Storage(format!("libSQL control-plane lock poisoned: {error}"))
.map_err(|error| ControlPlaneError::Storage(format!("libSQL control-plane lock poisoned: {error}"))) })
} }
} }
@@ -1003,6 +1003,49 @@ fn row_to_ai_external_conversation_binding(
}) })
} }
fn row_to_ai_tool_event(row: &libsql::Row) -> libsql::Result<AiToolEventRecord> {
Ok(AiToolEventRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
provider: row.get(5)?,
provider_session_id: row.get(6)?,
tool_name: row.get(7)?,
allowed: row.get(8)?,
deny_reason: row.get(9)?,
root_uri: row.get(10)?,
page_path: row.get(11)?,
normalized_file_path: row.get(12)?,
diff_summary: row.get(13)?,
citation_count: row.get(14)?,
before_file_version: row.get(15)?,
after_file_version: row.get(16)?,
payload_json: row.get(17)?,
created_at: row.get(18)?,
deleted_at: row.get(19)?,
})
}
fn row_to_ai_file_patch(row: &libsql::Row) -> libsql::Result<AiFilePatchRecord> {
Ok(AiFilePatchRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
session_id: row.get(3)?,
run_id: row.get(4)?,
tool_event_id: row.get(5)?,
root_uri: row.get(6)?,
relative_path: row.get(7)?,
before_file_version: row.get(8)?,
after_file_version: row.get(9)?,
patch_summary_json: row.get(10)?,
created_at: row.get(11)?,
deleted_at: row.get(12)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String { fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json) let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok() .ok()
@@ -1165,6 +1208,22 @@ impl ControlPlaneStore for TursoControlPlaneStore {
}) })
} }
fn list_users(&self, limit: usize) -> Result<Vec<UserRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let limit = limit.min(1000).max(1);
let mut stmt = conn.prepare(
"SELECT id, email, username, display_name, role, status, created_at, updated_at, revision
FROM users
ORDER BY created_at ASC, id ASC
LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_user)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
fn create_password_identity( fn create_password_identity(
&self, &self,
input: CreatePasswordIdentityInput, input: CreatePasswordIdentityInput,
@@ -3493,6 +3552,153 @@ impl ControlPlaneStore for TursoControlPlaneStore {
)?; )?;
Ok(changed) Ok(changed)
} }
// -----------------------------------------------------------------------
// P2: AI tool events (receipts) — 7-71
// -----------------------------------------------------------------------
fn append_ai_tool_event(
&self,
input: AppendAiToolEventInput,
) -> Result<AiToolEventRecord, ControlPlaneError> {
if input.user_id.trim().is_empty() || input.session_id.trim().is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai tool event user_id/session_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.payload_json)?;
let conn = self.lock_conn()?;
let record = AiToolEventRecord {
id: input.id.unwrap_or_else(|| new_id("ate")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
provider: input.provider,
provider_session_id: input.provider_session_id,
tool_name: input.tool_name,
allowed: input.allowed,
deny_reason: input.deny_reason,
root_uri: input.root_uri,
page_path: input.page_path,
normalized_file_path: input.normalized_file_path,
diff_summary: input.diff_summary,
citation_count: input.citation_count,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
payload_json: input.payload_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_tool_events (id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.provider, record.provider_session_id, record.tool_name, record.allowed, record.deny_reason,
record.root_uri, record.page_path, record.normalized_file_path, record.diff_summary, record.citation_count,
record.before_file_version, record.after_file_version, record.payload_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_tool_events(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiToolEventRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, provider, provider_session_id, tool_name, allowed, deny_reason, root_uri, page_path, normalized_file_path, diff_summary, citation_count, before_file_version, after_file_version, payload_json, created_at, deleted_at
FROM ai_tool_events
WHERE user_id = ?1 AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_tool_event,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
// -----------------------------------------------------------------------
// P2: AI file patches — 7-71
// -----------------------------------------------------------------------
fn append_ai_file_patch(
&self,
input: AppendAiFilePatchInput,
) -> Result<AiFilePatchRecord, ControlPlaneError> {
if input.user_id.trim().is_empty()
|| input.session_id.trim().is_empty()
|| input.tool_event_id.trim().is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"ai file patch user_id/session_id/tool_event_id 不能为空".to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.patch_summary_json)?;
let conn = self.lock_conn()?;
let record = AiFilePatchRecord {
id: input.id.unwrap_or_else(|| new_id("afp")),
user_id: input.user_id,
workspace_id: input.workspace_id,
session_id: input.session_id,
run_id: input.run_id,
tool_event_id: input.tool_event_id,
root_uri: input.root_uri,
relative_path: input.relative_path,
before_file_version: input.before_file_version,
after_file_version: input.after_file_version,
patch_summary_json: input.patch_summary_json,
created_at: now_text(),
deleted_at: None,
};
conn.execute(
"INSERT INTO ai_file_patches (id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
record.id, record.user_id, record.workspace_id, record.session_id, record.run_id,
record.tool_event_id, record.root_uri, record.relative_path,
record.before_file_version, record.after_file_version, record.patch_summary_json, record.created_at
],
)?;
Ok(record)
}
fn list_ai_file_patches(
&self,
user_id: &str,
session_id: Option<&str>,
limit: usize,
) -> Result<Vec<AiFilePatchRecord>, ControlPlaneError> {
let conn = self.lock_conn()?;
let mut stmt = conn.prepare(
"SELECT id, user_id, workspace_id, session_id, run_id, tool_event_id, root_uri, relative_path, before_file_version, after_file_version, patch_summary_json, created_at, deleted_at
FROM ai_file_patches
WHERE user_id = ?1
AND deleted_at IS NULL
AND (?2 IS NULL OR session_id = ?2)
ORDER BY created_at DESC
LIMIT ?3",
)?;
let rows = stmt
.query_map(
params![user_id, session_id, limit as i64],
row_to_ai_file_patch,
)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
Ok(rows)
}
} }
fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str { fn max_permission<'a>(left: &'a str, right: &'a str) -> &'a str {
@@ -3582,6 +3788,48 @@ mod tests {
assert_eq!(updated.revision, 2); assert_eq!(updated.revision, 2);
} }
#[test]
fn list_users_returns_stable_ordering() {
let store = store();
// Create 3 users in known order
let u1 = create_user(&store, "bravo");
let u2 = create_user(&store, "alpha");
let u3 = create_user(&store, "charlie");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 3, "should return all three users");
// Order must be by created_at ASC, id ASC
// u1 (bravo) created first, u2 (alpha) second, u3 (charlie) third
assert_eq!(users[0].id, u1.id, "first created should be first");
assert_eq!(users[1].id, u2.id, "second created should be second");
assert_eq!(users[2].id, u3.id, "third created should be third");
}
#[test]
fn list_users_respects_limit() {
let store = store();
for i in 0..5usize {
create_user(&store, &format!("user{i}"));
}
let users = store.list_users(3).expect("list users with limit");
assert_eq!(users.len(), 3, "should respect limit=3");
}
#[test]
fn list_users_excludes_password_data() {
let store = store();
create_user(&store, "nopassword");
let users = store.list_users(10).expect("list users");
assert_eq!(users.len(), 1);
// UserRecord has no password_hash field — this is a compile-time
// guarantee that list_users cannot leak password data.
assert!(users[0].email.is_some());
}
#[test] #[test]
fn ensure_default_workspace_creates_owner_grant() { fn ensure_default_workspace_creates_owner_grant() {
let store = store(); let store = store();
@@ -4757,7 +5005,6 @@ mod tests {
.is_none()); .is_none());
} }
// --- Fault injection tests --- // --- Fault injection tests ---
#[test] #[test]
@@ -4900,4 +5147,128 @@ mod tests {
"expected RateLimit: prefix, got {display}" "expected RateLimit: prefix, got {display}"
); );
} }
#[test]
fn ai_tool_events_append_and_list() {
let store = store();
create_user(&store, "tool_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_1".to_string()),
provider: "doubao-web".to_string(),
provider_session_id: Some("ps_1".to_string()),
tool_name: "mnote.local_file.read".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("page.md".to_string()),
normalized_file_path: Some("/tmp/page.md".to_string()),
diff_summary: Some("read file".to_string()),
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append tool event");
assert_eq!(event.tool_name, "mnote.local_file.read");
assert!(event.allowed);
assert_eq!(event.citation_count, 0);
store
.append_ai_tool_event(AppendAiToolEventInput {
id: None,
user_id: "tool_user".to_string(),
workspace_id: Some("ws_1".to_string()),
session_id: "sess_1".to_string(),
run_id: Some("run_2".to_string()),
provider: "reasonix".to_string(),
provider_session_id: None,
tool_name: "fs.write".to_string(),
allowed: false,
deny_reason: Some("path not in allowed roots".to_string()),
root_uri: "file:///etc".to_string(),
page_path: None,
normalized_file_path: Some("/etc/passwd".to_string()),
diff_summary: None,
citation_count: 0,
before_file_version: None,
after_file_version: None,
payload_json: "{}".to_string(),
})
.expect("append denied event");
let events = store
.list_ai_tool_events("tool_user", Some("sess_1"), 10)
.expect("list events");
assert_eq!(events.len(), 2);
assert_eq!(events[0].tool_name, "fs.write");
assert!(!events[0].allowed);
let all_events = store
.list_ai_tool_events("tool_user", None, 10)
.expect("list all");
assert_eq!(all_events.len(), 2);
}
#[test]
fn ai_file_patches_append_and_list() {
let store = store();
create_user(&store, "patch_user");
let event = store
.append_ai_tool_event(AppendAiToolEventInput {
id: Some("ate_turso_patch_ref".to_string()),
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
provider: "openclaw".to_string(),
provider_session_id: None,
tool_name: "mnote.local_file.patch".to_string(),
allowed: true,
deny_reason: None,
root_uri: "file:///tmp".to_string(),
page_path: Some("test.md".to_string()),
normalized_file_path: Some("/tmp/test.md".to_string()),
diff_summary: Some("edit file".to_string()),
citation_count: 0,
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
payload_json: "{}".to_string(),
})
.expect("append reference tool event");
let patch = store
.append_ai_file_patch(AppendAiFilePatchInput {
id: None,
user_id: "patch_user".to_string(),
workspace_id: None,
session_id: "sess_p1".to_string(),
run_id: None,
tool_event_id: event.id.clone(),
root_uri: "file:///tmp".to_string(),
relative_path: "test.md".to_string(),
before_file_version: Some("v1".to_string()),
after_file_version: Some("v2".to_string()),
patch_summary_json: r#"{"insertions":5}"#.to_string(),
})
.expect("append file patch");
assert_eq!(patch.tool_event_id, event.id);
assert!(patch.deleted_at.is_none());
let patches = store
.list_ai_file_patches("patch_user", Some("sess_p1"), 10)
.expect("list patches");
assert_eq!(patches.len(), 1);
let all_patches = store
.list_ai_file_patches("patch_user", None, 10)
.expect("list all");
assert_eq!(all_patches.len(), 1);
}
} }
@@ -257,7 +257,9 @@ fn libsql_local_store_handles_parallel_control_plane_writes() {
} }
for handle in handles { for handle in handles {
handle.join().expect("parallel writer thread should not panic"); handle
.join()
.expect("parallel writer thread should not panic");
} }
assert_eq!( assert_eq!(
@@ -275,10 +277,7 @@ fn libsql_local_store_handles_parallel_control_plane_writes() {
8 8
); );
assert_eq!( assert_eq!(
store store.list_audit_log(20).expect("list parallel audit").len(),
.list_audit_log(20)
.expect("list parallel audit")
.len(),
8 8
); );
assert_eq!( assert_eq!(
File diff suppressed because it is too large Load Diff
@@ -950,6 +950,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
menu.innerHTML = menu.innerHTML =
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-profile" role="menuitem"><span class="material-symbols-outlined" data-icon="account_circle" aria-hidden="true"></span><span>个人信息</span></button>' + '<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-profile" role="menuitem"><span class="material-symbols-outlined" data-icon="account_circle" aria-hidden="true"></span><span>个人信息</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-access-policy" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span><span>授权管理</span></button>' + '<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-access-policy" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span><span>授权管理</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-ai-management" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="smart_toy" aria-hidden="true"></span><span>AI 管理</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退出登录</button>' + '<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退出登录</button>' +
'<div class="mnote-account-menu__error" data-account-error hidden></div>'; '<div class="mnote-account-menu__error" data-account-error hidden></div>';
var sessionPromise = fetchAccountSession(); var sessionPromise = fetchAccountSession();
@@ -972,6 +973,20 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => {
openAdminAccessPolicyDialog(accessPolicyLink); openAdminAccessPolicyDialog(accessPolicyLink);
}); });
} }
var aiManagementLink = menu.querySelector('[data-testid="mnote-account-ai-management"]');
sessionPromise.then(function(session) {
if (!(aiManagementLink instanceof HTMLElement)) return;
aiManagementLink.hidden = false;
aiManagementLink.setAttribute('data-ai-management-role', sessionIsAdmin(session) ? 'admin' : 'user');
});
if (aiManagementLink) {
aiManagementLink.addEventListener('click', function(event) {
event.preventDefault();
sessionPromise.then(function(session) {
window.location.assign(sessionIsAdmin(session) ? '/admin/ai' : '/user/ai');
});
});
}
var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]'); var signOutButton = menu.querySelector('[data-testid="mnote-account-sign-out"]');
if (signOutButton) { if (signOutButton) {
signOutButton.addEventListener('click', function(event) { signOutButton.addEventListener('click', function(event) {
+3
View File
@@ -31,6 +31,7 @@ pub struct AppConfig {
pub enable_legacy_next_compat: bool, pub enable_legacy_next_compat: bool,
pub enable_debug_shell_routes: bool, pub enable_debug_shell_routes: bool,
pub enable_editor_actor: bool, pub enable_editor_actor: bool,
pub enable_page_ai_pi_lab: bool,
pub hermes_base_path: String, pub hermes_base_path: String,
pub compat_next_base_path: String, pub compat_next_base_path: String,
pub convex_url: Option<String>, pub convex_url: Option<String>,
@@ -64,6 +65,7 @@ impl AppConfig {
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false), .unwrap_or(false),
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true), enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true),
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH") hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
.unwrap_or_else(|_| "/api/hermes".into()), .unwrap_or_else(|_| "/api/hermes".into()),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH") compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
@@ -382,6 +384,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: true,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
File diff suppressed because it is too large Load Diff
@@ -167,6 +167,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -73,6 +73,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -112,6 +113,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -168,6 +170,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -236,6 +239,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
+3 -8
View File
@@ -164,10 +164,7 @@ pub async fn seed(
Ok(Json(DevSeedResponse { ok: true, results })) Ok(Json(DevSeedResponse { ok: true, results }))
} }
fn apply_seed_operation( fn apply_seed_operation(state: &AppState, operation: DevSeedOperation) -> Result<Value, WebError> {
state: &AppState,
operation: DevSeedOperation,
) -> Result<Value, WebError> {
match operation { match operation {
DevSeedOperation::SetupWorkspace { DevSeedOperation::SetupWorkspace {
user_id, user_id,
@@ -478,6 +475,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: false, enable_editor_actor: false,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -573,10 +571,7 @@ mod tests {
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile"); assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix"); assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["status"], "running"); assert_eq!(payload["results"][0]["run"]["status"], "running");
assert_eq!( assert_eq!(payload["results"][0]["events"].as_array().unwrap().len(), 1);
payload["results"][0]["events"].as_array().unwrap().len(),
1
);
// Step 3: getAiRuntimeRun → 回读验证 // Step 3: getAiRuntimeRun → 回读验证
let (status, payload) = send_seed( let (status, payload) = send_seed(
@@ -1196,6 +1196,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -1779,6 +1779,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: true, enable_debug_shell_routes: true,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -388,6 +388,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
+115
View File
@@ -261,6 +261,88 @@ pub async fn user_access_policy_entry(
Ok(response) Ok(response)
} }
pub async fn admin_ai_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
if !is_local_access_policy_admin_context(&context) {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"ai_admin_required",
"只有管理员可以访问 AI 管理中心",
)
.with_context(&context));
}
ai_management_response(&state, &context, true)
}
pub async fn user_ai_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
ai_management_response(&state, &context, false)
}
fn ai_management_response(
state: &AppState,
context: &RequestContext,
is_admin: bool,
) -> Result<Response, WebError> {
let workspace_name = default_workspace_name_for_context(state, context);
let content = crate::ssr::render_view(leptos::view! {
<crate::ssr::pages::ai_admin::AiManagementPage
workspace_name={workspace_name}
is_admin={is_admin}
/>
});
let title = if is_admin { "AI 管理" } else { "AI 设置" };
let shell = if is_admin {
"admin-ai"
} else {
"user-ai-admin"
};
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="{}" data-mnote-actor-id="{}">
{}
</body>
</html>"#,
title,
crate::ssr::MNOTE_CSS,
shell,
escape_html(context.auth.actor_id.as_str()),
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
pub async fn root_entry( pub async fn root_entry(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -579,6 +661,21 @@ pub async fn root_entry(
escape_script_json(&panes_bootstrap_json), escape_script_json(&panes_bootstrap_json),
render_editor_island_adapter_script(), render_editor_island_adapter_script(),
); );
let body_extra = if state.config().enable_page_ai_pi_lab {
body_extra
+ &format!(
r#"<script>
(function() {{
var s = document.createElement('script');
s.src = '/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#
)
} else {
body_extra
};
("MNOTE".to_string(), render_workspace_entry(), body_extra) ("MNOTE".to_string(), render_workspace_entry(), body_extra)
} else { } else {
match build_page_aggregate_snapshot( match build_page_aggregate_snapshot(
@@ -639,6 +736,21 @@ pub async fn root_entry(
render_document_title_controller_script(), render_document_title_controller_script(),
render_editor_island_adapter_script(), render_editor_island_adapter_script(),
); );
let body_extra = if state.config().enable_page_ai_pi_lab {
body_extra
+ &format!(
r#"<script>
(function() {{
var s = document.createElement('script');
s.src = '/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#
)
} else {
body_extra
};
(title.to_string(), content, body_extra) (title.to_string(), content, body_extra)
} }
Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()), Err(_) => ("MNOTE".to_string(), render_workspace_entry(), String::new()),
@@ -2554,6 +2666,7 @@ mod tests {
enable_legacy_next_compat, enable_legacy_next_compat,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url, convex_url,
@@ -3074,6 +3187,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -3366,6 +3480,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -166,6 +166,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -1450,7 +1450,9 @@ pub async fn toggle_skill(
return Ok(( return Ok((
StatusCode::OK, StatusCode::OK,
stamp_client_headers(), stamp_client_headers(),
Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_control_plane"})), Json(
json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_control_plane"}),
),
)); ));
} }
let access = let access =
@@ -14553,6 +14555,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -15520,6 +15523,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -15562,6 +15566,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -15991,7 +15996,10 @@ mod tests {
.await .await
.expect("detail body"); .expect("detail body");
let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json"); let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json");
assert_eq!(detail_payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE); assert_eq!(
detail_payload["persistence"],
ACP_RUNTIME_CONTROL_PLANE_STORE
);
assert_eq!( assert_eq!(
detail_payload["session"]["runs"][0]["sessionId"], detail_payload["session"]["runs"][0]["sessionId"],
session_id session_id
@@ -16399,6 +16407,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url), convex_url: Some(convex_url),
@@ -16558,6 +16567,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url), convex_url: Some(convex_url),
@@ -16646,6 +16656,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url), convex_url: Some(convex_url),
@@ -16722,6 +16733,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url), convex_url: Some(convex_url),
@@ -16905,6 +16917,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: Some(convex_url), convex_url: Some(convex_url),
@@ -1113,6 +1113,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -1180,6 +1181,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -282,6 +282,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -504,6 +504,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -9630,6 +9630,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -2147,10 +2147,7 @@ fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
Err(error) => { Err(error) => {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"local_search_index_delete_failed", "local_search_index_delete_failed",
format!( format!("无法删除本地搜索索引文件 {}: {error}", index_path.display()),
"无法删除本地搜索索引文件 {}: {error}",
index_path.display()
),
)); ));
} }
} }
@@ -486,6 +486,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -352,6 +352,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -420,6 +421,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
+55 -3
View File
@@ -1,8 +1,9 @@
mod ai_settings;
mod bridge; mod bridge;
pub(crate) mod command_support; pub(crate) mod command_support;
mod compat; mod compat;
mod dev_seed;
pub(crate) mod dev_hot; pub(crate) mod dev_hot;
mod dev_seed;
mod documents; mod documents;
mod editor; mod editor;
pub(crate) mod evidence; pub(crate) mod evidence;
@@ -28,6 +29,7 @@ pub(crate) mod onlyoffice_bridge;
mod page_ai_board; mod page_ai_board;
mod page_ai_opencode; mod page_ai_opencode;
mod page_ai_openhub; mod page_ai_openhub;
mod page_ai_pi;
mod page_ai_workflow; mod page_ai_workflow;
mod query_support; mod query_support;
mod resource_trash; mod resource_trash;
@@ -49,8 +51,7 @@ pub(crate) use local_folder_source::{
control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access, control_plane_status_display, decode_local_id_segment, ensure_local_path_read_access,
ensure_local_workspace_access, ensure_local_workspace_read_access_with_state, ensure_local_workspace_access, ensure_local_workspace_read_access_with_state,
ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key, ensure_local_workspace_write_access_with_state, local_markdown_conflict_detection_key,
local_workspace_id_from_root_uri, update_local_markdown_title, local_workspace_id_from_root_uri, update_local_markdown_title, write_local_markdown_page_body,
write_local_markdown_page_body,
}; };
#[cfg(test)] #[cfg(test)]
pub(crate) use local_search_index::write_local_index_settings; pub(crate) use local_search_index::write_local_index_settings;
@@ -84,6 +85,8 @@ pub fn build_router(state: AppState) -> Router {
"/user/access-policy", "/user/access-policy",
get(gateway::user_access_policy_entry), get(gateway::user_access_policy_entry),
) )
.route("/admin/ai", get(gateway::admin_ai_entry))
.route("/user/ai", get(gateway::user_ai_entry))
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry)) .route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
.route("/search", get(search::shell)) .route("/search", get(search::shell))
.route("/knowledge", get(gateway::root_entry)) .route("/knowledge", get(gateway::root_entry))
@@ -275,6 +278,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js", "/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
get(web_shell::sidebar_page_ai_target_runtime_asset), get(web_shell::sidebar_page_ai_target_runtime_asset),
) )
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
get(web_shell::sidebar_page_ai_pi_lab_runtime_asset),
)
.route( .route(
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js", "/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
get(web_shell::sidebar_page_settings_runtime_asset), get(web_shell::sidebar_page_settings_runtime_asset),
@@ -588,6 +595,27 @@ pub fn build_router(state: AppState) -> Router {
"/api/page-ai/board/runs/{run_id}/cancel", "/api/page-ai/board/runs/{run_id}/cancel",
post(page_ai_board::cancel_run), post(page_ai_board::cancel_run),
) )
.route("/api/page-ai/pi/status", get(page_ai_pi::status))
.route("/api/page-ai/pi/bootstrap", post(page_ai_pi::bootstrap))
.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/abort", post(page_ai_pi::abort))
.route("/api/page-ai/pi/events", get(page_ai_pi::events))
.route("/api/page-ai/pi/sessions", get(page_ai_pi::list_sessions))
.route(
"/api/page-ai/pi/sessions/{session_id}",
get(page_ai_pi::get_session_history),
)
.route(
"/api/page-ai/pi/sessions/{session_id}/events",
get(page_ai_pi::get_session_events),
)
.route("/api/page-ai/pi/tool-call", post(page_ai_pi::tool_call))
.route(
"/api/page-ai/pi/tool-call-bridge",
post(page_ai_pi::tool_call_bridge),
)
.route("/page-ai/pi", get(page_ai_pi::shell))
.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),
@@ -628,6 +656,29 @@ pub fn build_router(state: AppState) -> Router {
"/api/user/access-policy/grants/{grant_id}", "/api/user/access-policy/grants/{grant_id}",
delete(local_folder_source::delete_user_access_grant), delete(local_folder_source::delete_user_access_grant),
) )
.route(
"/api/ai-settings/effective",
get(ai_settings::effective_settings),
)
.route(
"/api/ai-settings/access-scopes",
get(ai_settings::user_access_scopes),
)
.route(
"/api/ai-admin/access-scopes",
get(ai_settings::admin_access_scopes),
)
.route("/api/ai-settings/receipts", get(ai_settings::user_receipts))
.route("/api/ai-admin/receipts", get(ai_settings::admin_receipts))
.route(
"/api/ai-admin/settings",
get(ai_settings::admin_get_settings).put(ai_settings::admin_put_settings),
)
.route("/api/ai-admin/users", get(ai_settings::admin_list_users))
.route(
"/api/ai-admin/users/{user_id}/settings",
get(ai_settings::admin_get_user_settings).put(ai_settings::admin_put_user_settings),
)
.route( .route(
"/api/admin/share-links", "/api/admin/share-links",
get(local_folder_source::get_share_links).post(local_folder_source::create_share_link), get(local_folder_source::get_share_links).post(local_folder_source::create_share_link),
@@ -1035,6 +1086,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes, enable_debug_shell_routes,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -369,6 +369,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -1998,6 +1998,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
File diff suppressed because it is too large Load Diff
@@ -611,6 +611,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -1163,6 +1163,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -776,6 +776,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -906,6 +907,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -1517,6 +1519,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -229,6 +229,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -331,6 +332,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -400,6 +402,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -480,6 +483,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
+1
View File
@@ -358,6 +358,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
+3
View File
@@ -2656,6 +2656,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: true, enable_debug_shell_routes: true,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -3001,6 +3002,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: true, enable_debug_shell_routes: true,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -3682,6 +3684,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: true, enable_debug_shell_routes: true,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
+45 -1
View File
@@ -298,6 +298,8 @@ pub async fn document_page_shell(
secondary_requested, secondary_requested,
secondary_invalid, secondary_invalid,
); );
let pi_lab_loader_script =
render_page_ai_pi_lab_loader_script(state.config().enable_page_ai_pi_lab);
let body_content = crate::ssr::render_view(leptos::view! { let body_content = crate::ssr::render_view(leptos::view! {
<DocumentPage <DocumentPage
title={title.to_string()} title={title.to_string()}
@@ -339,6 +341,7 @@ pub async fn document_page_shell(
{} {}
{} {}
{} {}
{}
</body> </body>
</html>"#, </html>"#,
escape_html(title), escape_html(title),
@@ -368,6 +371,7 @@ pub async fn document_page_shell(
r#"<script type="module" src="{}"></script>"#, r#"<script type="module" src="{}"></script>"#,
mnote_browser_runtime_src("document-conflict-panel-runtime.js") mnote_browser_runtime_src("document-conflict-panel-runtime.js")
), ),
pi_lab_loader_script,
); );
let mut response = Html(html).into_response(); let mut response = Html(html).into_response();
stamp_shell_headers(response.headers_mut(), "document"); stamp_shell_headers(response.headers_mut(), "document");
@@ -850,6 +854,23 @@ pub(crate) fn render_editor_island_adapter_script() -> String {
) )
} }
pub(crate) fn render_page_ai_pi_lab_loader_script(enabled: bool) -> String {
if !enabled {
return String::new();
}
format!(
r#"<script>
(function() {{
var s = document.createElement('script');
s.src = '{}';
s.onload = function() {{ if (window.createSidebarPageAiPiLabRuntime) window.createSidebarPageAiPiLabRuntime({{}}); }};
document.body.appendChild(s);
}})();
</script>"#,
mnote_browser_runtime_src("sidebar-page-ai-pi-lab-runtime.js")
)
}
fn leptos_tiptap_runtime_src(asset: &str) -> String { fn leptos_tiptap_runtime_src(asset: &str) -> String {
let base = format!("/api/leptos-tiptap-runtime/{asset}"); let base = format!("/api/leptos-tiptap-runtime/{asset}");
if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() { if let Some(cache_buster) = crate::routes::dev_hot::dev_hot_cache_buster() {
@@ -2591,6 +2612,20 @@ pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty())) .unwrap_or_else(|_| Response::new(Body::empty()))
} }
pub async fn sidebar_page_ai_pi_lab_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_settings_runtime_asset() -> Response { pub async fn sidebar_page_settings_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js"); const JS: &str = include_str!("../../browser/sidebar-page-settings-runtime.js");
Response::builder() Response::builder()
@@ -3669,6 +3704,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -3726,6 +3762,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -3847,6 +3884,7 @@ mod tests {
enable_legacy_next_compat: false, enable_legacy_next_compat: false,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:9".into()), convex_url: Some("http://127.0.0.1:9".into()),
@@ -3989,6 +4027,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -4729,6 +4768,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -4797,6 +4837,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -4935,7 +4976,8 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header() { async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header(
) {
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-ui-pref-{}", "mnote-local-page-aggregate-ui-pref-{}",
std::process::id() std::process::id()
@@ -4954,6 +4996,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
@@ -5499,6 +5542,7 @@ mod tests {
enable_legacy_next_compat: true, enable_legacy_next_compat: true,
enable_debug_shell_routes: false, enable_debug_shell_routes: false,
enable_editor_actor: true, enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
convex_url: None, convex_url: None,
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
//! SSR 页面组件 //! SSR 页面组件
pub mod admin; pub mod admin;
pub mod ai_admin;
pub mod auth; pub mod auth;
pub mod document; pub mod document;
pub mod home; pub mod home;
+112
View File
@@ -415,6 +415,35 @@ function getProcessNameByPid(pid) {
} }
} }
function getProcessCommandByPid(pid) {
if (process.platform === "win32") {
try {
const out = execSync(`wmic process where ProcessId=${pid} get CommandLine /value`, {
encoding: "utf8",
});
return out.replace(/^CommandLine=/m, "").trim();
} catch {
return "";
}
}
try {
return execSync(`ps -p ${pid} -o args=`, { encoding: "utf8" }).trim();
} catch {
return "";
}
}
function getProcessGroupByPid(pid) {
if (process.platform === "win32") return pid;
try {
const pgid = Number(execSync(`ps -p ${pid} -o pgid=`, { encoding: "utf8" }).trim());
return Number.isFinite(pgid) && pgid > 0 ? pgid : pid;
} catch {
return pid;
}
}
function terminatePid(pid) { function terminatePid(pid) {
if (process.platform === "win32") { if (process.platform === "win32") {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" });
@@ -441,6 +470,84 @@ function forceKillPid(pid) {
} }
} }
function terminateProcessGroup(pgid) {
if (process.platform === "win32") {
terminatePid(pgid);
return;
}
try {
process.kill(-pgid, "SIGTERM");
} catch {
terminatePid(pgid);
}
}
function forceKillProcessGroup(pgid) {
if (process.platform === "win32") {
forceKillPid(pgid);
return;
}
try {
process.kill(-pgid, "SIGKILL");
} catch {
forceKillPid(pgid);
}
}
function collectStaleMnoteWebCargoPids() {
if (process.platform === "win32") return [];
const currentPgid = getProcessGroupByPid(process.pid);
try {
const out = execSync("pgrep -f 'cargo (watch|run).*mnote-web|cargo-watch watch.*mnote-web'", {
encoding: "utf8",
});
return out
.split(/\r?\n/)
.map((line) => Number(line.trim()))
.filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid)
.filter((pid) => getProcessGroupByPid(pid) !== currentPgid)
.filter((pid) => {
const command = getProcessCommandByPid(pid);
return !command.includes("pgrep -f");
});
} catch {
return [];
}
}
async function stopStaleMnoteWebCargoProcesses() {
const pids = collectStaleMnoteWebCargoPids();
if (pids.length === 0) return true;
const safePids = pids.filter((pid) => {
const command = getProcessCommandByPid(pid);
return (
command.includes("mnote-web") &&
command.includes("cargo") &&
(command.includes("cargo watch") ||
command.includes("cargo-watch watch") ||
command.includes("cargo run -p mnote-web"))
);
});
if (safePids.length === 0) return true;
const pgids = [...new Set(safePids.map(getProcessGroupByPid).filter((pgid) => pgid > 0))];
logPrefix("mnote-web", `检测到陈旧 cargo-watch/cargo run,先清理进程组:${pgids.join(", ")}`);
pgids.forEach(terminateProcessGroup);
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150));
const remaining = collectStaleMnoteWebCargoPids().filter((pid) =>
safePids.includes(pid) || pgids.includes(getProcessGroupByPid(pid)),
);
if (remaining.length === 0) return true;
}
pgids.forEach(forceKillProcessGroup);
return true;
}
async function ensurePortFree(port, nameForLog) { async function ensurePortFree(port, nameForLog) {
const free = await isPortFree("127.0.0.1", port); const free = await isPortFree("127.0.0.1", port);
if (free) return true; if (free) return true;
@@ -665,6 +772,7 @@ async function main() {
// 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。 // 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。
const desiredFrontendPort = runtimePlan.publicPort; const desiredFrontendPort = runtimePlan.publicPort;
const frontendOwnerName = "mnote-web"; const frontendOwnerName = "mnote-web";
await stopStaleMnoteWebCargoProcesses();
const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName); const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName);
if (!frontendPortOk) { if (!frontendPortOk) {
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`); console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
@@ -762,8 +870,11 @@ module.exports = {
buildDefaultOpencodeCommand, buildDefaultOpencodeCommand,
buildDefaultOpenHubCommand, buildDefaultOpenHubCommand,
checkOpenHubHealth, checkOpenHubHealth,
collectStaleMnoteWebCargoPids,
ensurePortFree, ensurePortFree,
getListeningPidsByPort, getListeningPidsByPort,
getProcessCommandByPid,
getProcessGroupByPid,
getProcessNameByPid, getProcessNameByPid,
isHttpHealthy, isHttpHealthy,
isPortFree, isPortFree,
@@ -772,5 +883,6 @@ module.exports = {
resolveBackendExecutable, resolveBackendExecutable,
shouldStartBackend, shouldStartBackend,
shouldStartOpenHub, shouldStartOpenHub,
stopStaleMnoteWebCargoProcesses,
terminatePid, terminatePid,
}; };
+12
View File
@@ -5,6 +5,7 @@ const { test } = require("node:test");
const { const {
buildDefaultOpencodeCommand, buildDefaultOpencodeCommand,
buildDefaultOpenHubCommand, buildDefaultOpenHubCommand,
collectStaleMnoteWebCargoPids,
resolveBackendExecutable, resolveBackendExecutable,
ensurePortFree, ensurePortFree,
isHttpHealthy, isHttpHealthy,
@@ -13,6 +14,7 @@ const {
resolveRuntimePlan, resolveRuntimePlan,
shouldStartBackend, shouldStartBackend,
shouldStartOpenHub, shouldStartOpenHub,
stopStaleMnoteWebCargoProcesses,
} = require("./desktop-hot.js"); } = require("./desktop-hot.js");
function findFreePort() { function findFreePort() {
@@ -120,6 +122,16 @@ test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t
await waitForExit(child); await waitForExit(child);
}); });
test("陈旧 mnote-web cargo 清理不会匹配当前测试进程", async () => {
if (process.platform === "win32") {
return;
}
const pids = collectStaleMnoteWebCargoPids();
assert.ok(Array.isArray(pids));
assert.equal(pids.includes(process.pid), false);
await stopStaleMnoteWebCargoProcesses();
});
test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => { test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
const plan = resolveRuntimePlan({ const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000", FRONTEND_PORT: "3000",
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_AI_ADMIN_BASE || "http://127.0.0.1:3000";
const OUTPUT_DIR = process.env.MNOTE_AI_ADMIN_OUTPUT_DIR
|| path.join(__dirname, "..", "tmp", "ai-management-browser");
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 E2E_USER_EMAIL = process.env.MNOTE_AI_ADMIN_E2E_EMAIL || "ai-user@example.com";
const E2E_USER_NAME = process.env.MNOTE_AI_ADMIN_E2E_USERNAME || "ai-user";
const E2E_USER_PASSWORD = process.env.MNOTE_AI_ADMIN_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
async function quickLogin(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "commit" });
const button = page.getByRole("button", { name: "测试账号快速登录" });
await button.waitFor({ state: "visible" });
await Promise.all([
page.waitForURL((url) => !url.pathname.includes("/auth")),
button.click(),
]);
}
async function ensurePasswordUser(browser, user) {
const setupContext = await browser.newContext();
try {
const payload = (flow) => ({
action: "auth:signIn",
args: {
provider: "password",
params: {
email: user.email,
username: user.username,
name: user.name,
password: user.password,
flow,
},
},
});
let response = await setupContext.request.post(`${BASE}/api/auth`, {
data: payload("signIn"),
});
if (!response.ok()) {
response = await setupContext.request.post(`${BASE}/api/auth`, {
data: payload("signUp"),
});
}
assert(response.ok(), `测试用户 ${user.username} 准备失败: ${response.status()} ${await response.text()}`);
} finally {
await setupContext.close();
}
}
async function assertEventually(readValue, predicate, message, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
let lastValue;
while (Date.now() < deadline) {
lastValue = await readValue();
if (predicate(lastValue)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.fail(`${message}: ${JSON.stringify(lastValue)}`);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const runId = `${Date.now()}-${process.pid}`;
const skillName = `mnote-browser-smoke-${runId}`;
const mcpName = `lightrag-smoke-${runId}`;
const browser = await chromium.launch({
headless: process.env.MNOTE_AI_ADMIN_HEADED !== "1",
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
await ensurePasswordUser(browser, {
email: E2E_USER_EMAIL,
username: E2E_USER_NAME,
name: E2E_USER_NAME,
password: E2E_USER_PASSWORD,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
try {
await quickLogin(page);
const response = await page.goto(`${BASE}/admin/ai#ai-admin-models`, { waitUntil: "commit" });
assert(response && response.status() < 400, `AI 管理页加载失败: ${response && response.status()}`);
await page.locator('[data-testid="mnote-ai-admin-page"]').waitFor();
await page.locator("#ai-admin-models.is-active").waitFor();
const openHubLink = page.locator('a[href="/page-ai/openhub/admin"]');
assert(
await openHubLink.count() >= 1,
"OpenHub Admin 独立入口必须保留",
);
await page.getByRole("link", { name: "模型配置" }).click();
await page.locator("#ai-admin-models.is-active").waitFor();
const modelPanel = page.locator("#ai-admin-models");
await modelPanel.locator('[data-field="allowedModels"]').fill(
"omniroute/freefirst, omniroute/freefirst-fast",
);
await modelPanel.locator('[data-field="secretRef"]').fill("env://OMNIROUTE_API_KEY");
await modelPanel.getByRole("button", { name: "保存配置" }).click();
await modelPanel.locator("[data-ai-admin-models-save-status]").filter({ hasText: /已保存/ }).waitFor();
await page.screenshot({
path: path.join(OUTPUT_DIR, "models.png"),
fullPage: false,
});
await page.getByRole("link", { name: "工具权限" }).click();
await page.locator("#ai-admin-tools.is-active").waitFor();
const patchPolicy = page.locator('[data-ai-admin-tools-container] select').nth(4);
await patchPolicy.selectOption("ask");
await page.getByRole("button", { name: "保存策略" }).click();
await page.locator("[data-ai-admin-tools-save-status]").filter({ hasText: /已保存/ }).waitFor();
await page.getByRole("link", { name: "技能 / MCP" }).click();
await page.locator("#ai-admin-skills.is-active").waitFor();
await page.getByRole("button", { name: "+ 添加 Skill" }).click();
const skillRow = page.locator(".mnote-ai-admin-skill-row").last();
await skillRow.locator('[name="skillName"]').fill(skillName);
await skillRow.locator('[name="skillDescription"]').fill("浏览器验收技能");
await skillRow.locator('[name="skillEnabled"]').check();
await page.getByRole("button", { name: "+ 添加 MCP" }).click();
const mcpRow = page.locator("[data-mcp-idx]").last();
await mcpRow.locator('[name="mcpName"]').fill(mcpName);
await mcpRow.locator('[name="mcpTransport"]').selectOption("stdio");
await mcpRow.locator('[name="mcpCommand"]').fill("scripts/lightrag-native-mcp.sh");
await mcpRow.locator('[name="mcpSecretRefs"]').fill("env://LIGHTRAG_API_KEY");
await mcpRow.locator('[name="mcpEnabled"]').check();
const skillsPanel = page.locator("#ai-admin-skills");
await skillsPanel.getByRole("button", { name: "保存配置" }).click();
await assertEventually(
async () => (await skillsPanel.locator("[data-ai-admin-skills-save-status]").textContent()) || "",
(text) => text.includes("已保存"),
"技能/MCP 配置保存状态未变为已保存",
);
await page.screenshot({
path: path.join(OUTPUT_DIR, "skills-mcp.png"),
fullPage: false,
});
await page.getByRole("link", { name: "用户管理" }).click();
await page.locator("#ai-admin-users.is-active").waitFor();
const targetUser = page.locator('[data-ai-admin-users-list] [data-user-action="models"][data-user-id="ai-user"]');
await targetUser.waitFor();
await targetUser.click();
await page.locator('[data-ai-admin-user-settings] h3', { hasText: "ai-user" }).waitFor();
await page.locator('[data-user-model="omniroute/freefirst-fast"]').evaluate((input) => {
input.checked = false;
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.locator('[data-action="save-user-settings"]').click();
await page.locator("[data-ai-admin-user-save-status]").filter({ hasText: /已保存/ }).waitFor();
await page.locator('[data-user-tab="skills"]').click();
await page.locator(`[data-user-skill="${skillName}"]`).evaluate((input) => {
input.checked = false;
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await page.locator('[data-action="save-user-settings"]').click();
await page.locator("[data-ai-admin-user-save-status]").filter({ hasText: /已保存/ }).waitFor();
await page.screenshot({
path: path.join(OUTPUT_DIR, "users.png"),
fullPage: false,
});
await page.locator('[data-action="close-user-drawer"]').click();
await page.waitForFunction(() => {
const drawer = document.querySelector("[data-ai-admin-user-drawer]");
return !drawer || !drawer.classList.contains("is-open");
});
await page.getByRole("link", { name: "技能 / MCP" }).click();
await page.reload({ waitUntil: "commit" });
await page.locator("#ai-admin-skills.is-active").waitFor();
assert.equal(
await page.locator(`[name="skillName"][value="${skillName}"]`).count(),
1,
"Skill 保存后刷新必须仍存在",
);
assert.equal(
await page.locator(`[name="mcpName"][value="${mcpName}"]`).count(),
1,
"MCP 保存后刷新必须仍存在",
);
const effective = await page.evaluate(async () => {
const response = await fetch("/api/ai-settings/effective", { credentials: "include" });
return response.json();
});
assert.equal(effective.defaultModel, "omniroute/freefirst");
assert(
(effective.models || []).some((model) => model.id === "omniroute/freefirst-fast"),
"effective models 应包含管理员允许的模型",
);
assert(
(effective.skills || []).some((skill) => skill.name === skillName),
"effective skills 应包含已启用 skill",
);
assert(
(effective.mcpServers || []).some((server) => server.name === mcpName),
"effective MCP 应包含已启用 facade server",
);
const userSettings = await page.evaluate(async () => {
const response = await fetch("/api/ai-admin/users/ai-user/settings", { credentials: "include" });
return response.json();
});
assert.equal(
(userSettings.allowedModels || []).some((model) => model.id === "omniroute/freefirst-fast"),
false,
"ai-user 刷新后不应包含被禁用模型",
);
assert.equal(
(userSettings.skills || []).find((skill) => skill.id === skillName)?.enabled,
false,
"ai-user 刷新后应保留 Skill 禁用覆盖",
);
const userContext = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const userResponse = await userContext.request.post(`${BASE}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
email: E2E_USER_EMAIL,
username: E2E_USER_NAME,
name: E2E_USER_NAME,
password: E2E_USER_PASSWORD,
flow: "signIn",
},
},
},
});
assert(userResponse.ok(), `ai-user 登录失败: ${userResponse.status()} ${await userResponse.text()}`);
const userEffectiveResponse = await userContext.request.get(`${BASE}/api/ai-settings/effective`);
assert(userEffectiveResponse.ok(), `ai-user effective 读取失败: ${userEffectiveResponse.status()}`);
const userEffective = await userEffectiveResponse.json();
assert.equal(
(userEffective.models || []).some((model) => model.id === "omniroute/freefirst-fast"),
false,
"ai-user effective 不应包含被管理员取消的模型",
);
assert.equal(
(userEffective.skills || []).some((skill) => skill.name === skillName),
false,
"ai-user effective 不应包含被禁用 Skill",
);
await userContext.close();
console.log(JSON.stringify({
ok: true,
screenshots: {
models: path.join(OUTPUT_DIR, "models.png"),
users: path.join(OUTPUT_DIR, "users.png"),
skillsMcp: path.join(OUTPUT_DIR, "skills-mcp.png"),
},
effective: {
defaultModel: effective.defaultModel,
modelCount: (effective.models || []).length,
skillCount: (effective.skills || []).length,
mcpCount: (effective.mcpServers || []).length,
},
}, null, 2));
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,517 @@
#!/usr/bin/env node
"use strict";
// --------------------------------------------------------------------------
// 7-71 统一 AI 管理面板与 Pi Lab 功能接入 — 静态代码核查
// 脚本只读,不启动服务器。逐项检查代码结构中的路由注册、数据源逻辑、
// 安全约束、账户菜单入口和 Pi Lab persistence 存在性。
// --------------------------------------------------------------------------
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const REPO_ROOT = "/mnt/Data1T/mnote";
const PASS = "\x1b[32m✓\x1b[0m";
const FAIL = "\x1b[31m✗\x1b[0m";
const SKIP = "\x1b[33m\x1b[0m";
let passed = 0;
let failed = 0;
let skipped = 0;
function check(name, ok, detail) {
if (ok) {
console.log(` ${PASS} ${name}`);
if (detail) console.log(` ${detail}`);
passed++;
} else {
console.log(` ${FAIL} ${name}`);
if (detail) console.log(` ${detail}`);
failed++;
}
}
function checkSkipped(name, detail) {
console.log(` ${SKIP} ${name}`);
if (detail) console.log(` ${detail}`);
skipped++;
}
function readFile(p) {
const full = path.join(REPO_ROOT, p);
return fs.readFileSync(full, "utf8");
}
function fileExists(p) {
const full = path.join(REPO_ROOT, p);
return fs.existsSync(full);
}
function countMatches(text, pattern) {
const matches = text.match(pattern);
return matches ? matches.length : 0;
}
function routeHandlerInLines(lines, route, handler) {
return lines.some((l, i) => {
if (!l.includes(route)) return false;
if (l.includes(handler)) return true;
const nextLine = lines[i + 1] || "";
return nextLine.includes(handler);
});
}
// --------------------------------------------------------------------------
// Section 1: Routes Registration
// --------------------------------------------------------------------------
console.log("\n-- 1. Routes 注册 --");
const routesMod = readFile("rust/crates/mnote-web/src/routes/mod.rs");
const routesModLines = routesMod.split("\n");
check(
"/api/ai-settings/effective registered as GET",
routeHandlerInLines(routesModLines, '/api/ai-settings/effective"', 'get(ai_settings::effective_settings)'),
'Found get(ai_settings::effective_settings)'
);
check(
"/api/ai-settings/access-scopes registered as GET",
routeHandlerInLines(routesModLines, '/api/ai-settings/access-scopes"', 'get(ai_settings::user_access_scopes)'),
'Found get(ai_settings::user_access_scopes)'
);
check(
"/api/ai-admin/access-scopes registered as GET",
routeHandlerInLines(routesModLines, '/api/ai-admin/access-scopes"', 'get(ai_settings::admin_access_scopes)'),
'Found get(ai_settings::admin_access_scopes)'
);
check(
"/api/ai-settings/receipts registered as GET",
routeHandlerInLines(routesModLines, '/api/ai-settings/receipts"', 'get(ai_settings::user_receipts)'),
'Found get(ai_settings::user_receipts)'
);
check(
"/api/ai-admin/receipts registered as GET",
routeHandlerInLines(routesModLines, '/api/ai-admin/receipts"', 'get(ai_settings::admin_receipts)'),
'Found get(ai_settings::admin_receipts)'
);
check(
"/admin/ai registered",
routesMod.includes('/admin/ai", get(gateway::admin_ai_entry)'),
'gateway::admin_ai_entry'
);
check(
"/user/ai registered",
routesMod.includes('/user/ai", get(gateway::user_ai_entry)'),
'gateway::user_ai_entry'
);
const aiSettingsAccessScopesPostPutDelete = routesModLines.filter(function(l, i) {
var isAccessScopeRoute = l.includes('/api/ai-settings/access-scopes"') || l.includes('/api/ai-admin/access-scopes"');
if (!isAccessScopeRoute) return false;
var nextLine = routesModLines[i + 1] || "";
return nextLine.includes("post(") || nextLine.includes("put(") || nextLine.includes("delete(");
});
check(
"AI access-scopes routes have no POST/PUT/DELETE",
aiSettingsAccessScopesPostPutDelete.length === 0,
"All ai-settings/ai-admin access-scopes endpoints are GET-only"
);
// --------------------------------------------------------------------------
// Section 2: effective 数据仅从 directory_grants 生成
// --------------------------------------------------------------------------
console.log("\n-- 2. effective 代码 source of truth --");
var aiSettingsRs = readFile("rust/crates/mnote-web/src/routes/ai_settings.rs");
check(
"effective_settings uses load_active_directory_grants",
aiSettingsRs.includes("load_active_directory_grants(&state, &actor_id"),
"Uses directory_grants, not allowed_roots_json"
);
check(
"load_model_policy_and_quota never reads allowed_roots_json",
aiSettingsRs.includes("Never reads `allowed_roots_json`") &&
aiSettingsRs.includes("model_policy_json") &&
aiSettingsRs.includes("quota_json") &&
!aiSettingsRs.includes("allowed_roots_json,") &&
!aiSettingsRs.includes('"allowed_roots_json"'),
"Only reads model_policy_json and quota_json"
);
check(
"Response declares source_of_truth = 'directory_grants'",
countMatches(aiSettingsRs, 'SOURCE_OF_TRUTH') >= 1 &&
aiSettingsRs.includes('const SOURCE_OF_TRUTH: &str = "directory_grants"'),
"SOURCE_OF_TRUTH constant = directory_grants"
);
// --------------------------------------------------------------------------
// Section 3: AI 页面无 access scope POST/PUT/DELETE
// --------------------------------------------------------------------------
console.log("\n-- 3. AI 管理页面无 access-scope 写操作 --");
var aiAdminRs = readFile("rust/crates/mnote-web/src/ssr/pages/ai_admin.rs");
check(
"AI admin script only uses GET for access-scopes",
!aiAdminRs.includes("access-scopes').*POST") &&
!aiAdminRs.includes("access-scopes').*PUT") &&
!aiAdminRs.includes("access-scopes').*DELETE") &&
!aiAdminRs.includes("access-scopes\\\\', { method: 'POST'") &&
!aiAdminRs.includes("access-scopes\\\\', { method: 'PUT'") &&
!aiAdminRs.includes("access-scopes\\\\', { method: 'DELETE'"),
"Access-scopes fetch uses implicit GET; no POST/PUT/DELETE in script"
);
check(
"SSR template has no access-scopes write forms/buttons",
!aiAdminRs.includes('data-admin-form="create-share-grant"') &&
!aiAdminRs.includes('data-admin-action="revoke-access-grant"') &&
!aiAdminRs.includes('name="rootPath"') &&
!aiAdminRs.includes('name="targetUserId"'),
"No write form elements present"
);
check(
"requestJson calls for access-scopes don't supply POST/PUT/DELETE method",
!aiAdminRs.includes("requestJson('/api/ai-admin/access-scopes', { method: 'POST'") &&
!aiAdminRs.includes("requestJson('/api/ai-admin/access-scopes', { method: 'PUT'") &&
!aiAdminRs.includes("requestJson('/api/ai-settings/access-scopes', { method: 'POST'") &&
!aiAdminRs.includes("requestJson('/api/ai-settings/access-scopes', { method: 'PUT'"),
"No explicit POST/PUT/DELETE in fetch calls for access-scopes"
);
// --------------------------------------------------------------------------
// Section 4: 账户菜单有独立 AI 管理入口
// --------------------------------------------------------------------------
console.log("\n-- 4. 账户菜单 AI 管理入口 --");
var sidebarWorkspaceJs = readFile("rust/crates/mnote-web/browser/sidebar-workspace-runtime.js");
check(
"Account menu has 'AI 管理' entry with mnote-account-ai-management testid",
sidebarWorkspaceJs.includes('data-testid="mnote-account-ai-management"') &&
sidebarWorkspaceJs.includes("AI 管理"),
"Found mnote-account-ai-management element with AI 管理 text"
);
check(
"AI management entry navigates to /admin/ai or /user/ai",
sidebarWorkspaceJs.includes("'/admin/ai'") &&
sidebarWorkspaceJs.includes("'/user/ai'") &&
sidebarWorkspaceJs.includes("sessionIsAdmin(session) ? '/admin/ai' : '/user/ai'"),
"Navigates to /admin/ai for admin, /user/ai for user"
);
check(
"AI management entry has hidden attribute initially",
sidebarWorkspaceJs.includes('aiManagementLink.hidden = false') &&
sidebarWorkspaceJs.includes('data-ai-management-role'),
"Hidden initially; shown after session fetch"
);
// --------------------------------------------------------------------------
// Section 5: Pi history routes 与 journal calls
// --------------------------------------------------------------------------
console.log("\n-- 5. Pi Lab history routes & journal persistence --");
check(
"Pi Lab /api/page-ai/pi/sessions GET route exists",
routeHandlerInLines(routesModLines, '/api/page-ai/pi/sessions"', "get(page_ai_pi::list_sessions)"),
"list_sessions handler exists"
);
check(
"Pi Lab /api/page-ai/pi/sessions/{session_id} GET route exists",
routeHandlerInLines(routesModLines, '/api/page-ai/pi/sessions/{session_id}"', "get(page_ai_pi::get_session_history)"),
"get_session_history handler exists"
);
check(
"Pi Lab /api/page-ai/pi/sessions/{session_id}/events GET route exists",
routeHandlerInLines(routesModLines, '/api/page-ai/pi/sessions/{session_id}/events"', "get(page_ai_pi::get_session_events)"),
"get_session_events handler exists"
);
var pageAiPiRs = readFile("rust/crates/mnote-web/src/routes/page_ai_pi.rs");
check(
"Pi Lab persist_upsert_run writes to control_plane ai_runtime_runs",
pageAiPiRs.includes("persist_upsert_run") &&
pageAiPiRs.includes(".upsert_ai_runtime_run(input)") &&
pageAiPiRs.includes("build_upsert_run_input"),
"Uses control_plane().upsert_ai_runtime_run()"
);
check(
"Pi Lab persist_append_event writes to control_plane ai_runtime_events",
pageAiPiRs.includes("persist_append_event") &&
pageAiPiRs.includes(".append_ai_runtime_event(input)") &&
pageAiPiRs.includes("build_append_event_input"),
"Uses control_plane().append_ai_runtime_event()"
);
check(
"Pi Lab session list filters by pi_lab profile and pi acp_runtime",
pageAiPiRs.includes("r.profile == PI_LAB_PROFILE") &&
pageAiPiRs.includes("r.acp_runtime == PI_LAB_ACP_RUNTIME") &&
pageAiPiRs.includes('PI_LAB_PROFILE: &str = "pi_lab"') &&
pageAiPiRs.includes('PI_LAB_ACP_RUNTIME: &str = "pi"'),
"list_sessions filters to pi_lab/pi runs only"
);
check(
"Pi Lab receipts persist to control-plane",
pageAiPiRs.includes(".append_ai_tool_event(") &&
pageAiPiRs.includes(".append_ai_file_patch(") &&
pageAiPiRs.includes('"control_plane_turso_libsql_v1"'),
"Tool receipts and successful patches use ai_tool_events / ai_file_patches"
);
check(
"JSONL receipt storage is debug fallback only",
pageAiPiRs.includes('"provider_neutral_jsonl_debug_fallback_v1"') &&
pageAiPiRs.includes('"fallbackReason"'),
"JSONL is retained only after a control-plane write failure"
);
check(
"AI management page loads real sessions and receipts",
aiAdminRs.includes("/api/page-ai/pi/sessions?limit=20") &&
aiAdminRs.includes("/api/ai-admin/receipts?limit=50") &&
aiAdminRs.includes("data-ai-admin-receipts-body"),
"Sessions & Receipts is no longer a placeholder"
);
// --------------------------------------------------------------------------
// Section 6: OpenHub / Pi Lab 独立入口未删除
// --------------------------------------------------------------------------
console.log("\n-- 6. OpenHub / Pi Lab 独立入口未删除 --");
check(
"OpenHub agent route /page-ai/openhub/ai exists",
routesMod.includes('/page-ai/openhub/ai", get(page_ai_openhub::ai_shell)'),
"page_ai_openhub::ai_shell"
);
check(
"OpenHub admin routes still exist",
routesMod.includes('/page-ai/openhub/admin"') &&
routesMod.includes("page_ai_openhub::non_ai_route_guard"),
"non_ai_route_guard for admin"
);
check(
"OpenHub status API still exists",
routesMod.includes('/api/page-ai/openhub/status", get(page_ai_openhub::status)'),
"GET page_ai_openhub::status"
);
check(
"Pi Lab shell route exists",
routesMod.includes('/page-ai/pi", get(page_ai_pi::shell)'),
"page_ai_pi::shell"
);
check(
"Pi Lab event stream exists",
routesMod.includes('/api/page-ai/pi/events", get(page_ai_pi::events)'),
"GET page_ai_pi::events"
);
check(
"Pi Lab start/send/abort routes exist",
routesMod.includes('/api/page-ai/pi/start", post(page_ai_pi::start)') &&
routesMod.includes('/api/page-ai/pi/send", post(page_ai_pi::send)') &&
routesMod.includes('/api/page-ai/pi/abort", post(page_ai_pi::abort)'),
"POST start, send, abort"
);
// --------------------------------------------------------------------------
// Section 7: Admin 原子策略 GET/PUT
// --------------------------------------------------------------------------
console.log("\n-- 7. Admin 原子策略 GET/PUT --");
check(
"/api/ai-admin/settings GET+PUT route registered",
routesMod.includes('"/api/ai-admin/settings"') &&
routesMod.includes("get(ai_settings::admin_get_settings).put(ai_settings::admin_put_settings)"),
"模型、工具、Skills、MCP 由单一原子策略端点管理"
);
check(
"admin settings requires admin authorization",
aiSettingsRs.includes("ensure_admin(&context)?") &&
aiSettingsRs.includes("is_local_access_policy_admin_context"),
"GET/PUT 都复用现有管理员鉴权"
);
check(
"admin policy persists through ai_policies model_policy_json",
aiSettingsRs.includes("UpsertAiPolicyInput") &&
aiSettingsRs.includes("model_policy_json: merged_model_policy_json") &&
aiSettingsRs.includes("upsert_ai_policy"),
"不新增第二套配置真相"
);
check(
"effective settings projects tools, skills and MCP",
aiSettingsRs.includes("pub skills: Vec<SkillConfig>") &&
aiSettingsRs.includes("pub mcp_servers: Vec<McpServerConfig>") &&
aiSettingsRs.includes("effective_tool_catalog") &&
aiSettingsRs.includes("effective_skill_registry") &&
aiSettingsRs.includes("effective_mcp_registry"),
"用户侧只读取管理员策略的 effective 投影"
);
check(
"default Skills registry includes requested Pi capability set",
[
'"vpn".into()',
'"chrome-bridge".into()',
'"context7".into()',
'"searxng".into()',
'"global-search".into()',
'"mempalace".into()',
'"codegraph".into()',
].every((needle) => aiSettingsRs.includes(needle)) &&
aiSettingsRs.includes("default_skill_registry"),
"vpn/chrome-bridge/context7/searxng/global-search/mempalace/codegraph are installed as default Skills"
);
check(
"default MCP registry includes requested facade servers",
aiSettingsRs.includes("default_mcp_server_registry") &&
aiSettingsRs.includes("node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs") &&
aiSettingsRs.includes("https://mcp.context7.com/mcp") &&
aiSettingsRs.includes("node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs") &&
aiSettingsRs.includes("mempalace.mcp_server") &&
aiSettingsRs.includes("codegraph serve --mcp"),
"chrome-bridge/context7/searxng/mempalace/codegraph are installed as facade-only MCP defaults"
);
check(
"AI admin UI uses the atomic settings endpoint",
aiAdminRs.includes("/api/ai-admin/settings") &&
aiAdminRs.includes("method: 'PUT'") &&
!aiAdminRs.includes("/api/ai-admin/config"),
"UI 与后端合同一致"
);
// --------------------------------------------------------------------------
// Section 9: secretRef-only -- 无硬编码 API key
// --------------------------------------------------------------------------
console.log("\n-- 9. secretRef-only 检查 --");
check(
"ai_settings.rs accepts secret references only",
aiSettingsRs.includes('starts_with("env://")') &&
aiSettingsRs.includes('starts_with("secret://")') &&
aiSettingsRs.includes("不允许直接传 API Key"),
"Raw provider credentials are rejected"
);
check(
"ai_admin.rs refers to secrets only as env references, not hardcoded",
aiAdminRs.includes("API key 与 secret 不进前端、不硬编码"),
"ai_admin.rs declares secrets never enter frontend or hardcode"
);
check(
"page_ai_pi.rs uses env-based omniroute_api_key(), no hardcoded secrets",
pageAiPiRs.includes("PiLabToolFacade") &&
pageAiPiRs.includes("omniroute_api_key") &&
pageAiPiRs.includes("env_trimmed") &&
!pageAiPiRs.includes('"sk-') &&
!pageAiPiRs.includes('"secret') &&
!pageAiPiRs.includes('"API_KEY'),
"Pi Lab uses env-based key via omniroute_api_key() and env_trimmed"
);
// --------------------------------------------------------------------------
// Section 10: allowed roots 不可写
// --------------------------------------------------------------------------
console.log("\n-- 10. allowed roots 不可写检查 --");
check(
"No route with 'allowed-roots' accepting POST/PUT/DELETE",
!routesModLines.some(function(l) {
return (l.includes("allowed-roots") || l.includes("allowed_roots") || l.includes("allowedRoots")) &&
(l.includes("post(") || l.includes("put(") || l.includes("delete("));
}),
"No write-allowed-roots endpoint in routes"
);
check(
"ai_settings.rs has no write_allowed_roots / update_allowed_roots",
!aiSettingsRs.includes("write_allowed_roots") &&
!aiSettingsRs.includes("update_allowed_roots") &&
!aiSettingsRs.includes("save_allowed_roots"),
"No function for writing allowed roots in ai_settings.rs"
);
check(
"ai_admin.rs SSR has no allowed-roots write form elements",
!aiAdminRs.includes('name="allowedRoots"') &&
!aiAdminRs.includes('name="allowed_roots"') &&
!aiAdminRs.includes("data-ai-admin-allowed-roots-edit"),
"No allowed-roots editable fields in SSR template"
);
check(
"ai_admin script has no POST/PUT/DELETE for allowed-roots",
!aiAdminRs.includes("allowed-roots')") &&
!aiAdminRs.includes("allowed_roots')") &&
!aiAdminRs.includes("allowedRoots')"),
"No fetch calls for allowed-roots endpoints in ai_admin script"
);
// --------------------------------------------------------------------------
// Section 11: MCP facade-only
// --------------------------------------------------------------------------
console.log("\n-- 11. MCP facade-only 检查 --");
check(
"Pi Lab tool facade only exposes mnote. tools, no raw MCP passthrough",
pageAiPiRs.includes("mnote.current_page.read") &&
pageAiPiRs.includes("mnote.local_file.read") &&
pageAiPiRs.includes("mnote.knowledge_rag.query") &&
!pageAiPiRs.includes("tools/mcp") &&
!pageAiPiRs.includes("use_mcp") &&
!pageAiPiRs.includes("MCP_SERVER"),
"PiLabToolFacade registers only mnote. tools; no direct MCP"
);
check(
"ai_admin.rs MCP section says 'MNote facade' not 'raw MCP'",
aiAdminRs.includes("MCP 通过 MNote facade 管控") &&
aiAdminRs.includes("不出现 raw API key") &&
aiSettingsRs.includes("facadeOnly") &&
aiSettingsRs.includes("sandbox"),
"MCP section in ai_admin restricts raw MCP access via facade"
);
check(
"ai_admin.rs Knowledge section references MNote facade",
aiAdminRs.includes("MNote knowledge facade"),
"Pi knowledge goes through MNote facade, not direct"
);
// --------------------------------------------------------------------------
// 汇总
// --------------------------------------------------------------------------
console.log("\n===========================================");
console.log("通过: " + passed + " 失败: " + failed + " 跳过: " + skipped);
console.log("===========================================\n");
process.exit(failed > 0 ? 1 : 0);
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
// Pi Lab API endpoint smoke
// 验证 Pi Lab API 路由在 mnote-web 开发环境下的响应
// 需要 mnote-web 已在运行(npm run desktop:hot 或独立启动)
// 检查:新端点 start/send/abort/events、SSE、disabled builtin tools、receipt、no polling
const BASE = process.env.MNOTE_PI_LAB_BASE || 'http://127.0.0.1:3000';
const AUTH_COOKIE = process.env.MNOTE_PI_LAB_AUTH || '';
async function check(description, fn) {
try {
const result = await fn();
if (result.passed) {
console.log(`${description}`);
return true;
} else {
console.error(`${description}: ${result.reason}`);
return false;
}
} catch (err) {
console.error(`${description}: ${err.message}`);
return false;
}
}
async function fetchJson(url, options = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (AUTH_COOKIE && AUTH_COOKIE.toLowerCase().startsWith('bearer ')) headers.Authorization = AUTH_COOKIE;
else if (AUTH_COOKIE) headers.Cookie = AUTH_COOKIE;
const res = await fetch(url, { ...options, headers });
const body = await res.json();
return { status: res.status, body };
}
async function main() {
console.log(`\n🧪 Pi Lab API smoke (base: ${BASE})\n`);
const results = [];
// 1. Status route returns the stable status schema in either disabled or enabled mode.
results.push(await check('GET /api/page-ai/pi/status returns stable status schema', async () => {
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/status`);
if (status === 401 || status === 403) return { passed: true };
if (status !== 200) return { passed: false, reason: `status ${status}` };
if (typeof body.enabled !== 'boolean') return { passed: false, reason: 'missing enabled boolean' };
if (body.schema !== 'mnote.page_ai_pi.status.v1') return { passed: false, reason: `schema mismatch: ${body.schema}` };
if (body.uiMode !== 'independent_mnote_native_drawer' && body.enabled !== false) {
return { passed: false, reason: `unexpected uiMode: ${body.uiMode}` };
}
return { passed: true };
}));
// 2. Status response has managedPiSessionDirPolicy
results.push(await check('GET /api/page-ai/pi/status has managedPiSessionDirPolicy and receiptStorage', async () => {
const { body } = await fetchJson(`${BASE}/api/page-ai/pi/status`);
if (body.managedPiSessionDirPolicy) return { passed: true };
// Accept missing fields when disabled
if (body.enabled === false) return { passed: true };
return { passed: false, reason: 'missing managedPiSessionDirPolicy' };
}));
// 3. Status response has disabledPiBuiltinTools when enabled
results.push(await check('GET /api/page-ai/pi/status has disabledPiBuiltinTools', async () => {
const { body } = await fetchJson(`${BASE}/api/page-ai/pi/status`);
if (body.enabled === false) return { passed: true }; // skip when disabled
if (body.disabledPiBuiltinTools) return { passed: true };
return { passed: false, reason: 'missing disabledPiBuiltinTools' };
}));
// 4. Start endpoint exists and returns proper schema (may 404 if disabled)
results.push(await check('POST /api/page-ai/pi/start returns proper response (disabled may 401/404)', async () => {
const { status, body } = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
method: 'POST',
body: JSON.stringify({}),
});
if (status === 404 || status === 401 || status === 403) return { passed: true }; // disabled
// If enabled, check schema
if (body.schema === 'mnote.page_ai_pi.start.v1' || body.session) return { passed: true };
return { passed: true }; // Accept any non-error response
}));
// 5. Send endpoint schema
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`, {
method: 'POST',
body: JSON.stringify({ sessionId: 'test', message: 'hello' }),
});
if (status === 404 || status === 401 || status === 403) return { passed: true };
return { passed: true }; // routed correctly
}));
// 6. Abort endpoint
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`, {
method: 'POST',
body: JSON.stringify({ sessionId: 'test' }),
});
if (status === 404 || status === 401 || status === 403) return { passed: true };
return { passed: true };
}));
// 7. Events SSE endpoint returns proper content type
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`, {
headers: { Accept: 'text/event-stream' },
});
if (res.status === 404 || res.status === 401 || res.status === 403) return { passed: true };
const ct = res.headers.get('Content-Type') || '';
if (ct.includes('text/event-stream') || ct.includes('text/plain')) return { passed: true };
return { passed: false, reason: `unexpected Content-Type: ${ct}` };
}));
// 8. Tool call endpoint
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`, {
method: 'POST',
body: JSON.stringify({ toolName: 'mnote.allowed_roots.describe', params: {} }),
});
if (status === 404 || status === 401 || status === 403) return { passed: true };
return { passed: true };
}));
// 9. Bootstrap legacy endpoint
results.push(await check('POST /api/page-ai/pi/bootstrap returns 404 when disabled', async () => {
const { status } = await fetchJson(`${BASE}/api/page-ai/pi/bootstrap`, {
method: 'POST',
body: JSON.stringify({ prompt: 'test' }),
});
if (status === 404) return { passed: true };
return { passed: true }; // accept any response — mounted
}));
// 10. Runtime asset exists
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`);
if (res.status !== 200) return { passed: false, reason: `status ${res.status}` };
const text = await res.text();
if (!text.includes('createSidebarPageAiPiLabRuntime')) return { passed: false, reason: 'missing expected export' };
if (!text.includes('data-page-ai-pi-lab-drawer')) return { passed: false, reason: 'missing independent drawer marker' };
if (text.includes('attachPanelToDrawer') || text.includes('setOpenHubVisible')) return { passed: false, reason: 'runtime still references OpenHub drawer integration' };
if (text.includes('data-page-ai-pi-lab-openhub-tab')) return { passed: false, reason: 'runtime still exposes OpenHub tab inside Pi Lab' };
if (/setInterval\s*\(/.test(text)) return { passed: false, reason: 'runtime still has setInterval call' };
if (!text.includes('NO setInterval polling')) return { passed: false, reason: 'missing NO setInterval polling comment' };
if (!text.includes('EventSource')) return { passed: false, reason: 'missing EventSource for SSE' };
if (!text.includes('STATE_STARTED')) return { passed: false, reason: 'missing state machine states' };
if (!text.includes('/api/page-ai/pi/start')) return { passed: false, reason: 'missing /api/page-ai/pi/start endpoint' };
if (!text.includes('/api/page-ai/pi/send')) return { passed: false, reason: 'missing /api/page-ai/pi/send endpoint' };
if (!text.includes('/api/page-ai/pi/abort')) return { passed: false, reason: 'missing /api/page-ai/pi/abort endpoint' };
if (!text.includes('/api/page-ai/pi/events')) return { passed: false, reason: 'missing /api/page-ai/pi/events endpoint' };
return { passed: true };
}));
// Summary
const passed = results.filter(Boolean).length;
const total = results.length;
console.log(`\n📊 ${passed}/${total} passed`);
if (passed < total) {
console.error(`❌ Pi Lab API smoke: ${total - passed} failed`);
process.exit(1);
}
console.log('✅ Pi Lab API endpoint smoke passed.\n');
}
main().catch(err => {
console.error('❌ Smoke failed:', err.message);
process.exit(1);
});
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env node
// Pi Lab browser smoke
// 验证 Pi Lab 默认独立悬浮入口 + MNote-native drawer,不复用 OpenHub drawer/provider tab/iframe。
// 需要 mnote-web 已在运行;MNOTE_PAGE_AI_PI_LAB 默认开启,设为 0 时才强制关闭。
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3000";
const AUTH_COOKIE = process.env.MNOTE_PI_LAB_AUTH || "";
const AUTH_HEADER = process.env.MNOTE_PI_LAB_AUTH_HEADER || "";
const TARGET_URL = process.env.MNOTE_PI_LAB_BROWSER_URL || `${BASE}/`;
const UI_TIMEOUT_MS = parseInt(process.env.UI_TIMEOUT_MS || "20000", 10);
const SCREENSHOT = process.env.MNOTE_PI_LAB_SCREENSHOT || path.join(
__dirname,
"..",
"tmp",
`page-ai-pi-lab-drawer-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
);
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 addAuth(context) {
if (AUTH_HEADER) await context.setExtraHTTPHeaders({ Authorization: AUTH_HEADER });
if (!AUTH_COOKIE) return;
const cookies = AUTH_COOKIE.split(";").map((c) => {
const [name, ...rest] = c.trim().split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
});
await context.addCookies(cookies);
}
async function quickLoginIfNeeded(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
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: UI_TIMEOUT_MS }).catch(() => null),
quickLoginButton.click(),
]);
}
async function main() {
console.log(`\n🧪 Pi Lab browser smoke (base: ${BASE})\n`);
let browserRoot = process.env.MNOTE_PI_LAB_BROWSER_ROOT || "/tmp/mnote-pi-lab-browser-smoke";
const pagePath = "__pi_lab_browser_smoke.md";
let rootUri = `file://${browserRoot}`;
fs.mkdirSync(browserRoot, { recursive: true });
fs.writeFileSync(path.join(browserRoot, pagePath), "# Pi Lab browser smoke\n\nBrowser Original\n", "utf8");
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_LAB_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
await addAuth(context);
const page = await context.newPage();
try {
await quickLoginIfNeeded(page);
const response = await page.goto(TARGET_URL, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
const status = response ? response.status() : -1;
console.log(` 1. MNote shell status: ${status}, url: ${page.url()}`);
assert(status >= 200 && status < 400, `MNote shell should load, got ${status}`);
await page.waitForFunction(() => typeof window.createSidebarPageAiPiLabRuntime === "function", null, {
timeout: UI_TIMEOUT_MS,
});
await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
console.log(" 2. Pi Lab floating launcher visible");
await page.locator("[data-page-ai-pi-lab-launcher]").click();
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-pi-lab="panel"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
console.log(" 3. Pi Lab independent drawer visible");
const drawerEvidence = await page.evaluate(() => {
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
const piPanel = document.querySelector('[data-page-ai-pi-lab="panel"]');
const diagnostics = document.querySelector("[data-page-ai-pi-lab-diagnostics]");
return {
piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"),
panelInPiDrawer: Boolean(piDrawer && piPanel && piDrawer.contains(piPanel)),
panelInOpenHubDrawer: Boolean(openHubDrawer && piPanel && openHubDrawer.contains(piPanel)),
piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")),
openHubTabInPi: Boolean(piDrawer && piDrawer.querySelector("[data-page-ai-pi-lab-openhub-tab]")),
contextChips: document.querySelectorAll("[data-page-ai-pi-lab-context-strip] [data-page-ai-pi-lab-context]").length,
hasCurrentPageContext: Boolean(document.querySelector("[data-page-ai-pi-lab-current-page]")),
hasChangedFilesContext: Boolean(document.querySelector("[data-page-ai-pi-lab-changed-files]")),
diagnosticsClosed: diagnostics ? diagnostics.open === false : false,
model: document.querySelector("[data-page-ai-pi-lab-model-chip]")?.textContent || "",
status: document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "",
toolCount: document.querySelectorAll("[data-page-ai-pi-lab-tool-list] .wolai-page-ai-pi-lab-tool-pill").length,
};
});
assert(drawerEvidence.piDrawerVisible, "Pi Lab independent drawer should be visible");
assert(drawerEvidence.panelInPiDrawer, "Pi Lab panel should be mounted inside independent Pi drawer");
assert.equal(drawerEvidence.panelInOpenHubDrawer, false, "Pi Lab panel must not be inside OpenHub drawer");
assert.equal(drawerEvidence.piDrawerHasIframe, false, "Pi Lab drawer must not iframe a second app");
assert.equal(drawerEvidence.openHubTabInPi, false, "Pi Lab drawer must not expose OpenHub provider tab");
assert(drawerEvidence.contextChips >= 3, `expected context strip chips, got ${drawerEvidence.contextChips}`);
assert(drawerEvidence.hasCurrentPageContext, "context strip should retain current page binding");
assert(drawerEvidence.hasChangedFilesContext, "context strip should retain changed files count");
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.toolCount >= 5, `expected MNote tool rail, got ${drawerEvidence.toolCount}`);
console.log(" 4. Independent drawer, context strip and default model verified");
const startButton = page.locator("[data-page-ai-pi-lab-btn-start]");
if (await startButton.isVisible().catch(() => false)) {
await startButton.click();
}
await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, {
timeout: UI_TIMEOUT_MS,
});
const startStatusResp = await page.request.get(`${BASE}/api/page-ai/pi/status`);
assert(startStatusResp.ok(), `status after UI start should be OK, got ${startStatusResp.status()}`);
const startStatusData = await startStatusResp.json();
const sessionId = startStatusData.sessionId || startStatusData.session?.sessionId;
assert(sessionId, "UI start should create sessionId");
const firstAllowedRoot = startStatusData.session?.allowedRootsSnapshot?.roots?.[0] || null;
if (firstAllowedRoot?.rootPath) {
browserRoot = String(firstAllowedRoot.rootPath);
rootUri = String(firstAllowedRoot.rootUri || `file://${browserRoot}`);
fs.mkdirSync(browserRoot, { recursive: true });
fs.writeFileSync(path.join(browserRoot, pagePath), "# Pi Lab browser smoke\n\nBrowser Original\n", "utf8");
}
await page.waitForTimeout(800);
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
await page.waitForFunction(() => {
const button = document.querySelector("[data-page-ai-pi-lab-btn-send]");
return button && !button.disabled;
}, null, { timeout: UI_TIMEOUT_MS });
console.log(" 5. Runtime started and composer is interactive");
const deniedResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } },
});
assert(deniedResp.ok(), `deny tool call should return HTTP OK, got ${deniedResp.status()}`);
const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: {
sessionId,
toolName: "mnote.local_file.patch",
params: {
path: path.join(browserRoot, pagePath),
operations: [{ op: "replace", old: "Browser Original", new: "Browser Patched" }],
},
},
});
assert(patchResp.ok(), `patch tool call should return HTTP OK, got ${patchResp.status()}`);
const ragResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: { sessionId, toolName: "mnote.knowledge_rag.query", params: { rootUri, query: "Pi Lab browser smoke", topK: 1 } },
timeout: 8000,
}).catch((error) => ({ ok: () => false, status: () => `timeout: ${error.message}` }));
if (!ragResp.ok()) {
console.warn(` ! LightRAG direct tool call skipped in browser smoke: ${ragResp.status()}`);
}
const receiptResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: {
sessionId,
toolName: "mnote.tool_receipt.write",
params: {
citations: [{ title: "LightRAG mock citation", source: "lightrag-mock" }],
},
},
});
assert(receiptResp.ok(), `receipt tool call should return HTTP OK, got ${receiptResp.status()}`);
await page.waitForTimeout(800);
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-receipts]")?.textContent || "";
return text.includes("denied") && text.includes("diff") && text.includes("mnote.tool_receipt.write");
}, null, { timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
await page.waitForFunction(() => {
const root = document.querySelector('[data-page-ai-pi-lab="drawer"]');
const text = root?.textContent || "";
return text.includes("[Pi Lab mock] prompt accepted") && text.includes("LightRAG mock citation") && text.includes("patch");
}, null, { timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-pi-lab-btn-abort]").click();
await page.waitForFunction(() => (document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "").includes("aborted"), null, {
timeout: UI_TIMEOUT_MS,
});
const runtimeEvidence = await page.evaluate(() => {
const root = document.querySelector('[data-page-ai-pi-lab="drawer"]');
const text = root?.textContent || "";
return {
hasPromptReply: text.includes("[Pi Lab mock] prompt accepted"),
hasAborted: text.includes("aborted") || text.includes("已中止"),
hasDeny: text.includes("denied"),
hasReceipt: text.includes("mnote.local_file.patch") || text.includes("mnote.tool_receipt.write"),
hasCitation: text.includes("LightRAG mock citation") || text.includes("citations 1"),
hasDiff: text.includes("patch") || text.includes("diff"),
changedFiles: document.querySelector("[data-page-ai-pi-lab-changed-files]")?.textContent || "",
};
});
assert(runtimeEvidence.hasPromptReply, "Pi Lab should show streamed mock assistant reply");
assert(runtimeEvidence.hasAborted, "Pi Lab should show abort state");
assert(runtimeEvidence.hasDeny, "Pi Lab should show allowed-roots deny receipt");
assert(runtimeEvidence.hasReceipt, "Pi Lab should show tool receipt");
assert(runtimeEvidence.hasCitation, "Pi Lab should show LightRAG citation evidence");
assert(runtimeEvidence.hasDiff, "Pi Lab should show diff/changed file evidence");
assert.notEqual(runtimeEvidence.changedFiles, "0", "changed files chip should be non-zero");
console.log(" 6. Stream, abort, deny, citation, receipt and diff evidence visible");
const openHubEvidence = await page.evaluate(async () => {
const api = window.__mnoteSidebarPageAiRuntime;
if (api && typeof api.openPageAiDrawer === "function") {
api.openPageAiDrawer();
}
await new Promise((resolve) => setTimeout(resolve, 400));
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
const openHubFrame = document.querySelector("[data-page-ai-openhub-iframe]");
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
return {
openHubApiExists: Boolean(api && typeof api.openPageAiDrawer === "function"),
openHubDrawerExists: Boolean(openHubDrawer),
openHubHost: openHubDrawer?.getAttribute("data-page-ai-openhub-host") || "",
openHubFrameExists: Boolean(openHubFrame),
piStillIndependent: Boolean(piDrawer && openHubDrawer && !openHubDrawer.contains(piDrawer)),
};
});
assert(openHubEvidence.openHubApiExists, "OpenHub drawer API should still exist");
assert(openHubEvidence.openHubDrawerExists, "OpenHub drawer should still open independently");
assert.equal(openHubEvidence.openHubHost, "true", "OpenHub drawer should still be the default host");
assert(openHubEvidence.openHubFrameExists, "OpenHub iframe should still exist outside Pi Lab");
assert(openHubEvidence.piStillIndependent, "Pi Lab drawer should remain outside OpenHub drawer");
console.log(" 7. OpenHub default drawer still works independently");
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.screenshot({ path: SCREENSHOT, fullPage: false });
console.log(` 8. Screenshot: ${SCREENSHOT}`);
const statusResp = await page.request.get(`${BASE}/api/page-ai/pi/status`);
assert(statusResp.ok(), `status API should be OK, got ${statusResp.status()}`);
const statusData = await statusResp.json();
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.defaultModelId, "freefirst", "status default model");
console.log(" 9. Status API enabled and default model verified");
console.log("\n✅ Pi Lab browser smoke passed\n");
} catch (err) {
console.error(`\n❌ Pi Lab browser smoke failed: ${err.message}`);
process.exitCode = 1;
} finally {
await browser.close();
}
}
main();
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
// Pi Lab mock-mode API smoke
// 需要以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock 启动 mnote-webPi Lab 默认开启。
// 验证 start/send/abort/events、allowed roots、越界拒绝、当前页 read、文件 patch、receipt。
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const BASE = process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3000";
const AUTH = process.env.MNOTE_PI_LAB_AUTH || "Bearer pi-lab-smoke";
const ROOT = process.env.MNOTE_PI_LAB_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-lab-"));
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function fetchJson(url, options = {}) {
const headers = {
"Content-Type": "application/json",
Authorization: AUTH,
...(options.headers || {}),
};
const res = await fetch(url, { ...options, headers });
const text = await res.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
return { status: res.status, body, headers: res.headers };
}
async function main() {
fs.mkdirSync(ROOT, { recursive: true });
const pagePath = "page.md";
const pageFile = path.join(ROOT, pagePath);
fs.writeFileSync(pageFile, "# Pi Lab smoke\n\nOriginal body\n", "utf8");
const rootUri = `file://${ROOT}`;
console.log(`\n🧪 Pi Lab mock API smoke (base: ${BASE}, root: ${ROOT})\n`);
const status = await fetchJson(`${BASE}/api/page-ai/pi/status`);
assert(status.status === 200, `status endpoint returned ${status.status}`);
assert(status.body.schema === "mnote.page_ai_pi.status.v1", "status schema mismatch");
assert(status.body.enabled === true, "Pi Lab must be enabled for mock smoke");
assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode");
assert(status.body.runtimeMode === "mock", `runtimeMode must be mock, got ${status.body.runtimeMode}`);
console.log(" ✅ status enabled/mock");
const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
method: "POST",
body: JSON.stringify({
rootUri,
workspaceId: "pi-lab-smoke",
pagePath,
pageTitle: "Pi Lab smoke",
}),
});
assert(start.status === 200, `start returned ${start.status}: ${JSON.stringify(start.body)}`);
assert(start.body.schema === "mnote.page_ai_pi.start.v1", "start schema mismatch");
const sessionId = start.body.session && start.body.session.sessionId;
assert(sessionId, "start did not return sessionId");
assert(start.body.session.allowedRootsSnapshot, "start did not capture allowed roots snapshot");
console.log(` ✅ start session ${sessionId}`);
const events = await fetch(`${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`, {
headers: { Authorization: AUTH, Accept: "text/event-stream" },
});
assert(events.status === 200, `events returned ${events.status}`);
assert((events.headers.get("content-type") || "").includes("text/event-stream"), "events is not SSE");
if (events.body && events.body.cancel) await events.body.cancel();
console.log(" ✅ events SSE");
const allowed = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.allowed_roots.describe",
params: {},
}),
});
assert(allowed.status === 200, `allowed roots returned ${allowed.status}`);
assert(allowed.body.ok === true, "allowed roots tool should be allowed");
assert(Array.isArray(allowed.body.result.allowedRoots), "allowed roots missing");
console.log(" ✅ allowed roots describe");
const currentPage = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.current_page.read",
params: { rootUri, pagePath },
}),
});
assert(currentPage.status === 200 && currentPage.body.ok === true, "current page read failed");
assert(String(currentPage.body.result.content).includes("Original body"), "current page content mismatch");
console.log(" ✅ current page read");
const denied = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.local_file.read",
params: { path: "/etc/hosts" },
}),
});
assert(denied.status === 200, `deny read returned ${denied.status}`);
assert(denied.body.ok === false, "out-of-root read should be denied");
assert(denied.body.receipt, "denied read should still write receipt");
console.log(" ✅ out-of-root read denied with receipt");
const patch = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.local_file.patch",
params: {
rootUri,
path: pagePath,
operations: [{ op: "replace", old: "Original body", new: "Patched body" }],
},
}),
});
assert(patch.status === 200 && patch.body.ok === true, "local file patch failed");
assert(patch.body.result.polling === false, "patch must not request polling");
assert(String(patch.body.result.refresh || "").includes("watcher"), "patch must declare watcher refresh");
assert(fs.readFileSync(pageFile, "utf8").includes("Patched body"), "patched file content mismatch");
console.log(" ✅ local file patch + watcher refresh metadata");
const rag = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.knowledge_rag.query",
params: { rootUri, query: "Pi Lab smoke", topK: 1 },
}),
});
assert(rag.status === 200, `knowledge RAG facade returned HTTP ${rag.status}`);
assert(typeof rag.body.ok === "boolean", "knowledge RAG facade did not return tool envelope");
console.log(` ✅ LightRAG facade exercised (${rag.body.ok ? "ok" : "provider unavailable/denied"})`);
const reference = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.reference.open",
params: { rootUri, filePath: pagePath },
}),
});
assert(reference.status === 200, `reference.open facade returned HTTP ${reference.status}`);
assert(typeof reference.body.ok === "boolean", "reference.open facade did not return tool envelope");
console.log(` ✅ citation/open-reference facade exercised (${reference.body.ok ? "ok" : "provider unavailable/denied"})`);
const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
method: "POST",
body: JSON.stringify({ sessionId, message: "hello from smoke" }),
});
assert(send.status === 200 && send.body.accepted === true, "send did not accept prompt");
console.log(" ✅ send prompt");
const abort = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
method: "POST",
body: JSON.stringify({ sessionId }),
});
assert(abort.status === 200 && abort.body.aborted === true, "abort failed");
console.log(" ✅ abort");
console.log("\n✅ Pi Lab mock API smoke passed.\n");
}
main().catch((error) => {
console.error(`\n❌ Pi Lab mock API smoke failed: ${error.message}`);
process.exit(1);
});
+485
View File
@@ -0,0 +1,485 @@
#!/usr/bin/env node
// Pi Lab RPC-mode API smoke
// 验证 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc + MNOTE_PAGE_AI_PI_BIN wrapper 下:
// start/send/abort/tool-call/越界拒绝/.md patch
// 需要:mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh 启动
// 可选:MNOTE_PI_LAB_OPENAI_API_KEY(真实 send 需要 API key
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const BASE = process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3000";
const AUTH = process.env.MNOTE_PI_LAB_AUTH || "Bearer pi-lab-smoke";
const ROOT = process.env.MNOTE_PI_LAB_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-"));
const PI_BIN_ENV = process.env.MNOTE_PAGE_AI_PI_BIN || "/tmp/mnote-pi-cli-wrapper.sh";
const HAS_API_KEY = !!(process.env.MNOTE_PI_LAB_OPENAI_API_KEY || process.env.OPENAI_API_KEY);
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function fetchJson(url, options = {}) {
const headers = {
"Content-Type": "application/json",
Authorization: AUTH,
...(options.headers || {}),
};
const res = await fetch(url, { ...options, headers });
const text = await res.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
return { status: res.status, body, headers: res.headers };
}
/**
* Collect SSE events from /api/page-ai/pi/events for a short window.
*/
function collectSseEvents(sessionId, timeoutMs = 5000) {
return new Promise((resolve) => {
const events = [];
const url = `${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`;
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, timeoutMs);
fetch(url, {
headers: {
Authorization: AUTH,
Accept: "text/event-stream",
},
signal: controller.signal,
})
.then((res) => {
if (!res.ok) {
events.push({ error: `SSE returned ${res.status}` });
clearTimeout(timer);
resolve(events);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
function read() {
reader
.read()
.then(({ done, value }) => {
if (done) {
clearTimeout(timer);
resolve(events);
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const parsed = JSON.parse(line.slice(6));
events.push(parsed);
} catch {
// skip parse errors
}
}
}
read();
})
.catch((err) => {
if (err.name !== "AbortError") {
events.push({ error: err.message });
}
clearTimeout(timer);
resolve(events);
});
}
read();
})
.catch((err) => {
if (err.name !== "AbortError") {
events.push({ error: err.message });
}
clearTimeout(timer);
resolve(events);
});
});
}
/**
* Wait for a specific SSE event kind.
*/
async function waitForSseEvent(sessionId, targetKind, timeoutMs = 15000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const events = await collectSseEvents(sessionId, 3000);
const matched = events.find((e) => e.kind === targetKind);
if (matched) return matched;
await new Promise((r) => setTimeout(r, 500));
}
return null;
}
async function main() {
fs.mkdirSync(ROOT, { recursive: true });
const pagePath = "page.md";
const pageFile = path.join(ROOT, pagePath);
fs.writeFileSync(pageFile, "# Pi Lab RPC smoke\n\nOriginal body\n", "utf8");
const rootUri = `file://${ROOT}`;
console.log(`\n🧪 Pi Lab RPC API smoke (base: ${BASE}, root: ${ROOT})`);
console.log(` Pi binary: ${PI_BIN_ENV}`);
console.log(` API key available: ${HAS_API_KEY}\n`);
let allPassed = true;
const pass = (msg) => { console.log(`${msg}`); };
const fail = (msg) => { console.error(`${msg}`); allPassed = false; };
// ── 1. Status: enabled=true, runtimeMode=rpc ──────────────────────
try {
const status = await fetchJson(`${BASE}/api/page-ai/pi/status`);
assert(status.status === 200, `status returned ${status.status}`);
assert(status.body.schema === "mnote.page_ai_pi.status.v1", "status schema mismatch");
assert(status.body.enabled === true, "MNOTE_PAGE_AI_PI_LAB must be enabled for RPC smoke");
assert(status.body.runtimeMode === "rpc", `runtimeMode must be rpc, got ${status.body.runtimeMode}`);
assert(Array.isArray(status.body.disabledPiBuiltinTools), "missing disabledPiBuiltinTools");
assert(status.body.disabledPiBuiltinTools.includes("bash"), "bash should be in disabled list");
assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode");
pass("status enabled/rpc with disabled builtins and independent Pi Lab UI mode");
} catch (err) {
fail(`status check: ${err.message}`);
}
// ── 2. Start session (triggers real Pi RPC subprocess) ────────────
let sessionId = null;
try {
const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
method: "POST",
body: JSON.stringify({
rootUri,
workspaceId: "pi-lab-rpc-smoke",
pagePath,
pageTitle: "Pi Lab RPC smoke",
}),
});
assert(start.status === 200, `start returned ${start.status}: ${JSON.stringify(start.body)}`);
assert(start.body.schema === "mnote.page_ai_pi.start.v1", "start schema mismatch");
sessionId = start.body.session && start.body.session.sessionId;
assert(sessionId, "start did not return sessionId");
assert(start.body.session.runtimeMode === "rpc", `runtimeMode must be rpc, got ${start.body.session.runtimeMode}`);
assert(start.body.session.runtimePid, "RPC mode must have runtimePid (real Pi subprocess PID)");
assert(typeof start.body.session.runtimePid === "number", "runtimePid must be a number");
assert(start.body.session.runtimePid > 0, "runtimePid must be positive");
assert(start.body.session.allowedRootsSnapshot, "start did not capture allowed roots snapshot");
assert(Array.isArray(start.body.disabledPiBuiltinTools), "missing disabledPiBuiltinTools in start");
assert(start.body.mnoteToolOnly === true, "start must declare mnoteToolOnly");
pass(`start session ${sessionId} with real Pi PID ${start.body.session.runtimePid}`);
} catch (err) {
fail(`start: ${err.message}`);
}
// ── 3. SSE events endpoint ────────────────────────────────────────
if (sessionId) {
try {
const evtUrl = `${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`;
const evtRes = await fetch(evtUrl, {
headers: { Authorization: AUTH, Accept: "text/event-stream" },
signal: AbortSignal.timeout(3000),
}).catch(() => null);
if (evtRes && evtRes.ok) {
const ct = evtRes.headers.get("content-type") || "";
assert(ct.includes("text/event-stream"), `unexpected Content-Type: ${ct}`);
pass("events SSE endpoint returns text/event-stream");
} else {
// May fail if session has no events yet - accept 200 only
assert(evtRes && evtRes.status === 200, `events returned ${evtRes ? evtRes.status : "timeout"}`);
}
} catch (err) {
fail(`events SSE: ${err.message}`);
}
// ── 4. Verify runtime start evidence ───────────────────────────
// runtime_started may be published before the smoke attaches to SSE, so
// treat the start response runtimePid as the hard assertion and use SSE as
// opportunistic event evidence.
try {
const runtimeEvent = await waitForSseEvent(sessionId, "runtime_started", 5000);
if (runtimeEvent) {
const payload = runtimeEvent.payload || {};
assert(payload.mode === "rpc", `mode must be rpc, got ${payload.mode}`);
assert(typeof payload.pid === "number", "PID must be a number in event");
assert(Array.isArray(payload.disabledBuiltinTools), "missing disabledBuiltinTools in event");
pass(`runtime_started event observed (mode=rpc, pid=${payload.pid})`);
} else {
const statusAfterStart = await fetchJson(`${BASE}/api/page-ai/pi/status`);
assert(statusAfterStart.status === 200, `status after start returned ${statusAfterStart.status}`);
assert(statusAfterStart.body.running === true, "status after start must show running=true");
assert(statusAfterStart.body.pid, "status after start must expose runtime PID");
pass(`runtime start confirmed by status (pid=${statusAfterStart.body.pid}); runtime_started SSE was already consumed`);
}
} catch (err) {
fail(`runtime start evidence: ${err.message}`);
}
}
// ── 5. Tool call: mnote.allowed_roots.describe ────────────────────
if (sessionId) {
try {
const allowed = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.allowed_roots.describe",
params: {},
}),
});
assert(allowed.status === 200, `allowed roots returned ${allowed.status}`);
assert(allowed.body.ok === true, "allowed roots tool should be allowed");
assert(Array.isArray(allowed.body.result.allowedRoots), "allowed roots missing");
assert(allowed.body.receipt, "missing receipt");
assert(allowed.body.receipt.storage === "provider_neutral_jsonl_adapter_v1", "receipt storage mismatch");
pass("mnote.allowed_roots.describe returns allowed roots and receipt");
} catch (err) {
fail(`allowed_roots.describe: ${err.message}`);
}
// ── 6. Tool call: current page read ─────────────────────────────
try {
const currentPage = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.current_page.read",
params: { rootUri, pagePath },
}),
});
assert(currentPage.status === 200 && currentPage.body.ok === true, "current page read failed");
assert(String(currentPage.body.result.content).includes("Original body"), "current page content mismatch");
assert(currentPage.body.result.format === "markdown", "format must be markdown");
assert(currentPage.body.result.fileVersion, "missing fileVersion");
pass("mnote.current_page.read returns page content with fileVersion");
} catch (err) {
fail(`current_page.read: ${err.message}`);
}
// ── 7. Tool call: out-of-bounds read denied ─────────────────────
try {
const denied = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.local_file.read",
params: { path: "/etc/hosts" },
}),
});
assert(denied.status === 200, `deny read returned ${denied.status}`);
assert(denied.body.ok === false, "out-of-root read should be denied");
assert(denied.body.result.ok === false, "denied result must contain ok=false");
assert(denied.body.receipt, "denied read should still write receipt");
assert(denied.body.receipt.storage === "provider_neutral_jsonl_adapter_v1", "receipt storage mismatch");
pass("out-of-root read denied with receipt");
} catch (err) {
fail(`out-of-bound read: ${err.message}`);
}
// ── 8. Tool call: patch current .md file ────────────────────────
try {
const patch = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.local_file.patch",
params: {
rootUri,
path: pagePath,
operations: [{ op: "replace", old: "Original body", new: "Patched by RPC smoke" }],
},
}),
});
assert(patch.status === 200 && patch.body.ok === true, "local file patch failed");
assert(patch.body.result.polling === false, "patch must not request polling");
assert(String(patch.body.result.refresh || "").includes("watcher"), "patch must declare watcher refresh");
assert(patch.body.result.beforeFileVersion, "missing beforeFileVersion");
assert(patch.body.result.afterFileVersion !== patch.body.result.beforeFileVersion,
"file version must change after patch");
assert(patch.body.result.diffSummary, "missing diffSummary");
const patchedContent = fs.readFileSync(pageFile, "utf8");
assert(patchedContent.includes("Patched by RPC smoke"), "patched file content mismatch");
assert(!patchedContent.includes("Original body"), "old content should be replaced in file");
assert(patch.body.receipt, "missing receipt");
pass("mnote.local_file.patch writes file + watcher refresh + version change + receipt");
} catch (err) {
fail(`local_file.patch: ${err.message}`);
}
// ── 9. Tool call: LightRAG knowledge_rag.query facade ───────────
try {
const rag = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.knowledge_rag.query",
params: { rootUri, query: "Pi Lab RPC smoke", topK: 1 },
}),
});
assert(rag.status === 200, `knowledge RAG facade returned HTTP ${rag.status}`);
assert(typeof rag.body.ok === "boolean", "knowledge RAG facade did not return tool envelope");
assert(rag.body.receipt, "missing receipt");
// LightRAG may return empty results if no knowledge base indexed, that's OK
pass(`knowledge_rag.query facade exercised (ok=${rag.body.ok}, receipt present)`);
} catch (err) {
fail(`knowledge_rag.query: ${err.message}`);
}
// ── 10. Tool call: reference.open facade ────────────────────────
try {
const reference = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
body: JSON.stringify({
sessionId,
toolName: "mnote.reference.open",
params: { rootUri, filePath: pagePath },
}),
});
assert(reference.status === 200, `reference.open facade returned HTTP ${reference.status}`);
assert(typeof reference.body.ok === "boolean", "reference.open facade did not return tool envelope");
assert(reference.body.receipt, "missing receipt");
pass(`reference.open facade exercised (ok=${reference.body.ok})`);
} catch (err) {
fail(`reference.open: ${err.message}`);
}
// ── 11. Send prompt (only if API key available) ─────────────────
if (HAS_API_KEY) {
try {
const marker = `REAL_PI_TOOL_BRIDGE_OK_${Date.now()}`;
const pendingEvents = collectSseEvents(sessionId, 45000);
const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, {
method: "POST",
body: JSON.stringify({
sessionId,
message: `You must call the available tool named mnote_current_page_read before answering. After reading the page, reply exactly ${marker} if the tool result contains "Patched by RPC smoke". Do not explain.`,
}),
});
assert(send.status === 200 && send.body.accepted === true, "send did not accept prompt");
assert(send.body.schema === "mnote.page_ai_pi.send.v1", "send schema mismatch");
assert(send.body.eventStream, "send must return eventStream path");
pass("send accepts prompt and routes to Pi RPC stdin");
const events = await pendingEvents;
const eventText = JSON.stringify(events);
const hasPiToolStart = events.some((event) =>
event.kind === "pi_rpc_event"
&& event.payload?.type === "tool_execution_start"
&& event.payload?.toolName === "mnote_current_page_read"
);
const hasMnoteBridgeTool = events.some((event) =>
event.kind === "tool_call"
&& event.payload?.toolName === "mnote.current_page.read"
);
assert(hasPiToolStart, "Pi did not emit mnote_current_page_read tool_execution_start");
assert(hasMnoteBridgeTool, "MNote bridge did not execute mnote.current_page.read");
assert(eventText.includes(marker), "Pi final response marker not observed after tool call");
pass("real Pi custom tool call flows through MNote bridge and returns marker");
} catch (err) {
fail(`send: ${err.message}`);
}
} else {
console.log(" ⏭ Send/abort skipped (set MNOTE_PI_LAB_OPENAI_API_KEY for real Pi RPC prompt test)");
}
// ── 13. Abort ───────────────────────────────────────────────────
// Abort works even without API key - it kills the Pi subprocess
try {
const abort = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
method: "POST",
body: JSON.stringify({ sessionId }),
});
assert(abort.status === 200, `abort returned ${abort.status}`);
assert(abort.body.aborted === true, "abort must return aborted=true");
assert(abort.body.schema === "mnote.page_ai_pi.abort.v1", "abort schema mismatch");
pass("abort kills Pi RPC subprocess and returns aborted=true");
} catch (err) {
fail(`abort: ${err.message}`);
}
// ── 14. Verify session status changed to Aborted ────────────────
try {
const status2 = await fetchJson(`${BASE}/api/page-ai/pi/status`);
assert(status2.body.session, "status should return current session");
assert(
status2.body.session.status === "aborted",
`expected session status aborted, got ${status2.body.session.status}`
);
pass("session status transitions to aborted");
} catch (err) {
fail(`session status aborted: ${err.message}`);
}
// ── 15. Start a second session to verify multi-session lifecycle ─
try {
const start2 = await fetchJson(`${BASE}/api/page-ai/pi/start`, {
method: "POST",
body: JSON.stringify({
rootUri,
workspaceId: "pi-lab-rpc-smoke-2",
pagePath,
pageTitle: "Pi Lab RPC smoke 2",
}),
});
assert(start2.status === 200, `second start returned ${start2.status}`);
const session2Id = start2.body.session && start2.body.session.sessionId;
assert(session2Id, "second start did not return sessionId");
assert(session2Id !== sessionId, "second session must have different ID");
assert(start2.body.session.runtimePid, "second session must also have runtimePid");
pass(`second session ${session2Id} started with PID ${start2.body.session.runtimePid}`);
// Clean up second session
const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, {
method: "POST",
body: JSON.stringify({ sessionId: session2Id }),
});
assert(abort2.status === 200, `second abort returned ${abort2.status}`);
assert(abort2.body.aborted === true, "second abort must return aborted=true");
pass("second session abort cleans up Pi subprocess");
} catch (err) {
fail(`multi-session lifecycle: ${err.message}`);
}
}
// ── 16. Runtime browser asset integrity ───────────────────────────
try {
const res = await fetch(`${BASE}/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js`);
assert(res.status === 200, `runtime asset status ${res.status}`);
const text = await res.text();
assert(text.includes("createSidebarPageAiPiLabRuntime"), "missing expected export");
assert(!/setInterval\s*\(/.test(text), "runtime should not use setInterval polling");
assert(text.includes("NO setInterval polling"), "missing NO setInterval polling comment");
assert(text.includes("/api/page-ai/pi/start"), "missing start endpoint");
assert(text.includes("/api/page-ai/pi/send"), "missing send endpoint");
assert(text.includes("/api/page-ai/pi/abort"), "missing abort endpoint");
assert(text.includes("/api/page-ai/pi/events"), "missing events endpoint");
pass("client runtime JS served with correct endpoints and no polling");
} catch (err) {
fail(`runtime asset: ${err.message}`);
}
// ── Summary ───────────────────────────────────────────────────────
if (allPassed) {
console.log("\n✅ Pi Lab RPC API smoke passed.\n");
} else {
console.error("\n❌ Pi Lab RPC API smoke: some checks failed.\n");
process.exit(1);
}
}
main().catch((error) => {
console.error(`\n❌ Pi Lab RPC API smoke failed: ${error.message}`);
process.exit(1);
});
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env node
// Pi Lab RPC browser smoke
// 验证真实 Pi RPC + Omniroute/freefirst 在 MNote-native Pi Lab 抽屉中的可见 stream。
// 需要 mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,并配置 MNOTE_PAGE_AI_PI_BIN / Omniroute key。
"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_PI_LAB_BASE || "http://127.0.0.1:3000";
const AUTH = process.env.MNOTE_PI_LAB_AUTH || "Bearer pi-lab-smoke";
const ROOT = process.env.MNOTE_PI_LAB_BROWSER_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-browser-"));
const UI_TIMEOUT_MS = Number.parseInt(process.env.UI_TIMEOUT_MS || "45000", 10);
const MARKER = process.env.MNOTE_PI_LAB_RPC_MARKER || `REAL_PI_BROWSER_OK_${Date.now()}`;
const SCREENSHOT = process.env.MNOTE_PI_LAB_SCREENSHOT || path.join(
__dirname,
"..",
"tmp",
`page-ai-pi-lab-rpc-browser-${new Date().toISOString().replace(/[:.]/g, "-")}.png`,
);
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 authHeaders() {
if (!AUTH) return {};
if (AUTH.toLowerCase().startsWith("bearer ")) return { Authorization: AUTH };
return { Cookie: AUTH };
}
function pathMatchesPage(value, expectedPagePath) {
const normalized = String(value || "").replace(/\\/g, "/");
return normalized === expectedPagePath || normalized.endsWith(`/${expectedPagePath}`);
}
async function addAuth(context) {
const headers = authHeaders();
if (headers.Authorization) await context.setExtraHTTPHeaders({ Authorization: headers.Authorization });
if (!headers.Cookie) return;
const cookies = headers.Cookie.split(";").map((cookie) => {
const [name, ...rest] = cookie.trim().split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
});
await context.addCookies(cookies);
}
async function quickLoginIfNeeded(page) {
const authResponse = await page.goto(`${BASE}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
if (!authResponse || authResponse.status() >= 400) return;
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: UI_TIMEOUT_MS }).catch(() => null),
quickLoginButton.click(),
]);
}
async function main() {
let browserRoot = ROOT;
fs.mkdirSync(browserRoot, { recursive: true });
const pagePath = "__pi_lab_rpc_browser_smoke.md";
let pageFile = path.join(browserRoot, pagePath);
fs.writeFileSync(pageFile, "# Pi Lab RPC browser smoke\n\nBrowser RPC Original\n", "utf8");
let rootUri = `file://${browserRoot}`;
const documentId = `local-md:${pagePath}`;
console.log(`\n🧪 Pi Lab RPC browser smoke (base: ${BASE}, root: ${ROOT})`);
console.log(` marker: ${MARKER}\n`);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_LAB_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
await addAuth(context);
const page = await context.newPage();
const consoleErrors = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) {
consoleErrors.push(`${message.type()}: ${message.text()}`);
}
});
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`));
try {
const statusBefore = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() });
assert(statusBefore.ok(), `status before browser should be OK, got ${statusBefore.status()}`);
const statusBeforeJson = await statusBefore.json();
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.defaultModelProvider, "omniroute", "default provider must be omniroute");
assert.equal(statusBeforeJson.defaultModelId, "freefirst", "default model must be freefirst");
console.log(" ✅ status reports rpc + omniroute/freefirst");
await quickLoginIfNeeded(page);
if (!process.env.MNOTE_PI_LAB_BROWSER_URL) {
const defaultE2eRoot = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
if (fs.existsSync(defaultE2eRoot)) {
browserRoot = defaultE2eRoot;
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");
}
const loginStatus = await page.request.get(`${BASE}/api/page-ai/pi/status`);
if (loginStatus.ok()) {
const loginStatusJson = await loginStatus.json();
const firstAllowedRoot = loginStatusJson.session?.allowedRootsSnapshot?.roots?.[0]
|| loginStatusJson.allowedRootsSnapshot?.roots?.[0]
|| null;
if (!fs.existsSync(defaultE2eRoot) && 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");
}
}
}
const targetUrl = process.env.MNOTE_PI_LAB_BROWSER_URL
|| `${BASE}/documents/${documentId}?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}`;
const response = await page.goto(targetUrl, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
assert(response && response.status() >= 200 && response.status() < 400, `MNote shell load failed: ${response && response.status()}`);
await page.locator(".ProseMirror").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(() => null);
await page.waitForFunction(() => typeof window.createSidebarPageAiPiLabRuntime === "function", null, {
timeout: UI_TIMEOUT_MS,
});
await page.locator("[data-page-ai-pi-lab-launcher]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-pi-lab-launcher]").click();
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");
const startButton = page.locator("[data-page-ai-pi-lab-btn-start]");
let startJson = null;
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
const startRespPromise = page.waitForResponse((res) => res.url().includes("/api/page-ai/pi/start") && res.request().method() === "POST", {
timeout: UI_TIMEOUT_MS,
});
await startButton.click();
const startResp = await startRespPromise;
assert(startResp.ok(), `start response failed: ${startResp.status()}`);
startJson = await startResp.json();
assert(pathMatchesPage(startJson.session?.pagePath, pagePath), `start response should bind pagePath=${pagePath}, got ${startJson.session?.pagePath}`);
}
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return text.includes("ready");
}, null, { timeout: UI_TIMEOUT_MS });
const statusAfterStart = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() });
const statusAfterStartJson = await statusAfterStart.json();
const activeSession = startJson?.session || 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();
assert(currentPageChip && !/未绑定/.test(currentPageChip), `current page chip should be bound, got ${currentPageChip}`);
const editor = page.locator(".ProseMirror").first();
if (await editor.isVisible({ timeout: 5000 }).catch(() => false)) {
await page.evaluate(() => {
const root = document.querySelector(".ProseMirror");
if (!root) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node = null;
while ((node = walker.nextNode())) {
const index = String(node.textContent || "").indexOf("Browser RPC Original");
if (index >= 0) {
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + "Browser RPC Original".length);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
break;
}
}
});
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-selection]")?.textContent || "";
return text.includes("已选中");
}, null, { timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick="selection"]').click();
await page.waitForFunction(() => {
const input = document.querySelector("[data-page-ai-pi-lab-input]");
return (input?.value || "").includes("Browser RPC Original");
}, null, { timeout: UI_TIMEOUT_MS });
console.log(" ✅ selection quick action injects live tiptap selection into composer");
await page.locator('.wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick="read-page"]').click();
await page.waitForFunction(() => {
const input = document.querySelector("[data-page-ai-pi-lab-input]");
return (input?.value || "").includes("Browser RPC Original");
}, null, { timeout: UI_TIMEOUT_MS });
console.log(" ✅ current page quick action calls mnote.current_page.read");
}
const denyResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
headers: authHeaders(),
data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } },
});
assert(denyResp.ok(), `deny tool call HTTP ${denyResp.status()}`);
const denyJson = await denyResp.json();
assert.equal(denyJson.ok, false, "out-of-root read must be denied");
const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
headers: authHeaders(),
data: {
sessionId,
toolName: "mnote.local_file.patch",
params: {
path: pageFile,
operations: [{ op: "replace", old: "Browser RPC Original", new: "Browser RPC Patched" }],
},
},
});
assert(patchResp.ok(), `patch tool call HTTP ${patchResp.status()}`);
const patchJson = await patchResp.json();
assert.equal(patchJson.ok, true, "patch should be allowed");
assert.equal(patchJson.result.polling, false, "patch must not request polling");
assert(String(patchJson.result.refresh || "").includes("watcher"), "patch should declare watcher refresh");
assert(fs.readFileSync(pageFile, "utf8").includes("Browser RPC Patched"), "patch should update markdown file");
if (await editor.isVisible({ timeout: 1000 }).catch(() => false)) {
await page.waitForFunction(() => {
const editorNode = document.querySelector(".ProseMirror");
return (editorNode?.textContent || "").includes("Browser RPC Patched");
}, null, { timeout: UI_TIMEOUT_MS });
}
console.log(" ✅ allowed-roots deny and markdown patch receipt exercised");
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-receipts]")?.textContent || "";
return text.includes("denied") && text.includes("diff");
}, null, { timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-pi-lab-input]").fill(`请只回复 ${MARKER},不要解释。`);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const assistantMarker = page
.locator('[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text')
.filter({ hasText: MARKER })
.last();
await assistantMarker.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const visibleText = await assistantMarker.textContent();
assert(visibleText.includes(MARKER), "visible assistant bubble should contain 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");
console.log(" ✅ real Pi RPC stream rendered in UI without visible reasoning leakage");
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
await page.screenshot({ path: SCREENSHOT, fullPage: false });
console.log(` ✅ screenshot: ${SCREENSHOT}`);
const abortResp = await page.request.post(`${BASE}/api/page-ai/pi/abort`, {
headers: authHeaders(),
data: { sessionId },
});
assert(abortResp.ok(), `abort HTTP ${abortResp.status()}`);
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return text.includes("aborted");
}, null, { timeout: UI_TIMEOUT_MS });
console.log(" ✅ abort reflected in UI state");
const domEvidence = await page.evaluate(() => {
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
return {
piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"),
piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")),
piInsideOpenHub: Boolean(openHubDrawer && piDrawer && openHubDrawer.contains(piDrawer)),
model: document.querySelector("[data-page-ai-pi-lab-model-chip]")?.textContent || "",
};
});
assert(domEvidence.piDrawerVisible, "Pi drawer should remain visible");
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(domEvidence.model.includes("omniroute/freefirst"), `model chip mismatch: ${domEvidence.model}`);
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));
assert.equal(severe.length, 0, `severe console errors: ${severe.join(" | ")}`);
console.log("\n✅ Pi Lab RPC browser smoke passed\n");
} catch (error) {
console.error(`\n❌ Pi Lab RPC browser smoke failed: ${error.message}`);
if (consoleErrors.length) console.error(consoleErrors.slice(0, 10).join("\n"));
process.exitCode = 1;
} finally {
await browser.close();
}
}
main();
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env node
// Pi Lab static code smoke
// 验证 Pi Lab 相关源码结构正确,不依赖后端运行
// 确认:新端点、状态机、无轮询、SSE、Pi builtin 禁用、allowed roots、receipt
// 确认:默认模型 omniroute/freefirst 在前端 UI 和 header 中明确体现
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
// Files to check
const files = {
runtime: path.join(repoRoot, 'rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js'),
route: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/page_ai_pi.rs'),
mod: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/mod.rs'),
webShell: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/web_shell.rs'),
gateway: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/gateway.rs'),
app: path.join(repoRoot, 'rust/crates/mnote-web/src/app.rs'),
};
function readFile(p) {
try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
}
const runtime = readFile(files.runtime);
const route = readFile(files.route);
const routesMod = readFile(files.mod);
const webShell = readFile(files.webShell);
const gateway = readFile(files.gateway);
const app = readFile(files.app);
const checks = [
// === Runtime JS: existence ===
['runtime JS exists', runtime.length > 0],
['runtime exports createSidebarPageAiPiLabRuntime', runtime.includes('window.createSidebarPageAiPiLabRuntime')],
// === Runtime JS: API endpoints (new flow: start/send/abort/events) ===
['runtime uses /api/page-ai/pi/status', runtime.includes('/api/page-ai/pi/status')],
['runtime uses /api/page-ai/pi/start', runtime.includes('/api/page-ai/pi/start')],
['runtime uses /api/page-ai/pi/send', runtime.includes('/api/page-ai/pi/send')],
['runtime uses /api/page-ai/pi/abort', runtime.includes('/api/page-ai/pi/abort')],
['runtime uses /api/page-ai/pi/events', runtime.includes('/api/page-ai/pi/events')],
['runtime keeps legacy /api/page-ai/pi/bootstrap fallback', runtime.includes('/api/page-ai/pi/bootstrap')],
// === Runtime JS: state machine ===
['runtime has idle state', runtime.includes("STATE_IDLE") || (runtime.includes("'idle'") && runtime.includes('STATE_IDLE'))],
['runtime has starting state', runtime.includes("STATE_STARTING")],
['runtime has started state', runtime.includes("STATE_STARTED")],
['runtime has streaming state', runtime.includes("STATE_STREAMING")],
['runtime has aborted state', runtime.includes("STATE_ABORTED")],
['runtime has error state', runtime.includes("STATE_ERROR")],
// === Runtime JS: NO periodic polling ===
['runtime does NOT use setInterval for periodic polling',
!runtime.includes('setInterval(checkStatus') && !runtime.includes("setInterval(checkStatus") &&
!runtime.match(/setInterval\s*\([^)]*checkStatus/i)],
['runtime comments "NO setInterval polling"', runtime.includes('NO setInterval polling')],
// === Runtime JS: SSE / EventSource ===
['runtime uses EventSource for SSE', runtime.includes('EventSource')],
['runtime connects to /api/page-ai/pi/events via SSE', runtime.includes('API.EVENTS') || runtime.includes('/api/page-ai/pi/events')],
['runtime handles pi_rpc_event from SSE', runtime.includes('pi_rpc_event')],
['runtime handles runtime_started event', runtime.includes('runtime_started')],
['runtime handles runtime_aborted event', runtime.includes('runtime_aborted')],
// === Runtime JS: UI rendering ===
['runtime renders stream text', runtime.includes('text_delta') || runtime.includes('streamingAssistantMsg.text')],
['runtime renders tool calls', runtime.includes('toolCalls')],
['runtime renders citations', runtime.includes('citations')],
['runtime renders diff summary', runtime.includes('diffSummary')],
['runtime has start button', runtime.includes('btn-start')],
['runtime has send button', runtime.includes('btn-send')],
['runtime has abort button', runtime.includes('btn-abort')],
['runtime has clear button', runtime.includes('btn-clear')],
// === Runtime JS: Pi builtin disabled ===
['runtime handles disabledPiBuiltinTools from status/start', runtime.includes('disabledBuiltinTools') || runtime.includes('disabledPiBuiltinTools')],
['runtime has Pi builtin disabled UI indicator', runtime.includes('builtin-disabled')],
['runtime mentions bash/read/write/edit disabled', runtime.includes('bash') && runtime.includes('read') && runtime.includes('write') && runtime.includes('edit')],
// === Runtime JS: tool receipt ===
['runtime references receipt', runtime.includes('receipt') || runtime.includes('Receipt')],
['runtime has receipt UI display', runtime.includes('receipts')],
// === Runtime JS: independent native drawer ===
['runtime checks enabled flag via status API but does not hide launcher behind it', runtime.includes('enabled') && runtime.includes('checkStatus')],
['runtime renders independent Pi Lab drawer', runtime.includes('data-page-ai-pi-lab-drawer') && runtime.includes('data-page-ai-pi-lab') && runtime.includes('drawer')],
['runtime creates drawer through ensureDrawer', runtime.includes('function ensureDrawer') && runtime.includes('setDrawerVisible')],
['runtime does NOT mount inside OpenHub drawer', !runtime.includes('attachPanelToDrawer') && !runtime.includes('wolai-page-ai-drawer')],
['runtime does NOT toggle OpenHub iframe visibility', !runtime.includes('setOpenHubVisible') && !runtime.includes('data-page-ai-openhub-frame-wrap')],
['runtime has no OpenHub provider tab inside Pi Lab', !runtime.includes('data-page-ai-pi-lab-openhub-tab')],
['runtime has context strip chips', runtime.includes('data-page-ai-pi-lab-context-strip') && runtime.includes('data-page-ai-pi-lab-current-page') && runtime.includes('data-page-ai-pi-lab-allowed-roots') && runtime.includes('data-page-ai-pi-lab-lightrag')],
['runtime collapses secondary context/settings like OpenHub chrome', runtime.includes('<details class="wolai-page-ai-pi-lab-settings"') && runtime.includes('<details class="wolai-page-ai-pi-lab-context-strip"')],
['runtime has native model controls', runtime.includes('data-page-ai-pi-lab-model-provider') && runtime.includes('data-page-ai-pi-lab-model-id') && runtime.includes('data-page-ai-pi-lab-model-custom')],
['runtime collapses secondary right rail sections', runtime.includes('<details class="wolai-page-ai-pi-lab-rail-section"') && runtime.includes('data-page-ai-pi-lab-receipts-section')],
['runtime has diagnostics collapsed by default', runtime.includes('<details class="wolai-page-ai-pi-lab-diagnostics"') && !runtime.includes('<details class="wolai-page-ai-pi-lab-diagnostics" open')],
['runtime injects CSS styles', runtime.includes('injectStyles')],
// === Runtime JS: default model (omniroute/freefirst) ===
['runtime has defaultModelProvider with omniroute', runtime.includes('defaultModelProvider') && runtime.includes('omniroute')],
['runtime has defaultModelId with freefirst', runtime.includes('defaultModelId') && runtime.includes('freefirst')],
['runtime shows default model in header label', runtime.includes('updateModelLabel') && runtime.includes('defaultModelProvider') && runtime.includes('defaultModelId')],
['runtime consumes defaultModelProvider from backend status', runtime.includes('data.defaultModelProvider')],
['runtime consumes defaultModelId from backend status', runtime.includes('data.defaultModelId')],
['runtime shows "omniroute/freefirst" in empty state', runtime.includes('omniroute') && runtime.includes('freefirst') && runtime.includes('默认模型')],
['runtime comment mentions consuming backend default fields', runtime.includes('backend status/default fields')],
['runtime has DEFAULT_MODEL_PROVIDER constant', runtime.includes('DEFAULT_MODEL_PROVIDER') && runtime.includes("'omniroute'")],
['runtime has DEFAULT_MODEL_ID constant', runtime.includes('DEFAULT_MODEL_ID') && runtime.includes("'freefirst'")],
['runtime has Pi Lab floating launcher', runtime.includes('data-page-ai-pi-lab-launcher')],
['runtime documents pi-web-ui evidence', runtime.includes('@earendil-works/pi-web-ui@0.75.3')],
['runtime uses MNote-native adapter boundary', runtime.includes('MNote-native adapter')],
// === Route checks ===
['route file exists', route.length > 0],
['route has status endpoint', route.includes('pub async fn status')],
['route has start endpoint', route.includes('pub async fn start')],
['route has send endpoint', route.includes('pub async fn send')],
['route has abort endpoint', route.includes('pub async fn abort')],
['route has events SSE endpoint', route.includes('pub async fn events')],
['route has bootstrap endpoint (legacy)', route.includes('pub async fn bootstrap')],
['route has tool_call endpoint', route.includes('pub async fn tool_call')],
['route has internal tool_call_bridge endpoint', route.includes('pub async fn tool_call_bridge')],
['route uses AppConfig enable_page_ai_pi_lab instead of direct env gate', route.includes('state.config().enable_page_ai_pi_lab') && !route.includes('std::env::var("MNOTE_PAGE_AI_PI_LAB")')],
['route returns enabled=false when not enabled', route.includes('"enabled": false')],
['route marks independent native drawer ui mode', route.includes('independent_mnote_native_drawer')],
['route does not return openHubDefaultPreserved marker', !route.includes('openHubDefaultPreserved')],
['route returns schema mnote.page_ai_pi.status.v1', route.includes('mnote.page_ai_pi.status.v1')],
['route returns schema mnote.page_ai_pi.bootstrap.v1', route.includes('mnote.page_ai_pi.bootstrap.v1')],
['route returns schema mnote.page_ai_pi.start.v1', route.includes('mnote.page_ai_pi.start.v1')],
['route returns schema mnote.page_ai_pi.send.v1', route.includes('mnote.page_ai_pi.send.v1')],
['route returns schema mnote.page_ai_pi.abort.v1', route.includes('mnote.page_ai_pi.abort.v1')],
['route has event schema PI_LAB_SCHEMA_EVENT', route.includes('PI_LAB_SCHEMA_EVENT')],
['route has receipt schema PI_LAB_SCHEMA_RECEIPT', route.includes('PI_LAB_SCHEMA_RECEIPT')],
['route disables Pi builtin tools', route.includes('disabledPiBuiltinTools') || route.includes('--no-builtin-tools')],
['route has receipt storage policy', route.includes('receiptStorage') || route.includes('PI_LAB_SCHEMA_RECEIPT')],
['route has session dir policy', route.includes('managedPiSessionDirPolicy')],
['route enforces allowed roots', route.includes('active_allowed_roots') || route.includes('allowed_roots')],
['route blocks path escape', route.includes('path_escape') || route.includes('path_is_inside')],
['route has default model provider constant PI_LAB_DEFAULT_MODEL_PROVIDER=omniroute', route.includes('PI_LAB_DEFAULT_MODEL_PROVIDER') && route.includes('omniroute')],
['route has default model id constant PI_LAB_DEFAULT_MODEL_ID=freefirst', route.includes('PI_LAB_DEFAULT_MODEL_ID') && route.includes('freefirst')],
['route rejects arbitrary cookie_header auth bypass', route.includes('page_ai_pi_lab_invalid_session_cookie') && !route.includes('context.auth.cookie_header.is_some() {\n return Ok') ],
['route stores Pi Lab session owner id', route.includes('mnote_user_id') && route.includes('ensure_session_owner')],
['route validates session owner on send/abort/tool/events', route.includes('get_session_for_context')],
['route requires sidebar host selection snapshot', route.includes('page_ai_pi_lab_selection_snapshot_required') && route.includes('mnote_sidebar_host_snapshot')],
['route tool_receipt.write is not a silent no-op', route.includes('"requestedReceipt"') && route.includes('execute_tool 统一写入')],
['route has bridge token header and non-serialized session token', route.includes('HEADER_PI_LAB_BRIDGE_TOKEN') && route.includes('x-mnote-pi-lab-bridge-token') && route.includes('skip_serializing')],
['route generates bridge token from OS randomness', route.includes('generate_bridge_token') && route.includes('/dev/urandom') && !route.includes('bridge_token: generate_id("pi_bridge")')],
['route does not write bridge token literal into extension file', route.includes('MNOTE_PI_LAB_BRIDGE_TOKEN') && route.includes('process.env.MNOTE_PI_LAB_BRIDGE_TOKEN') && !route.includes('const BRIDGE_TOKEN = {bridge_token}')],
['route rejects bridge calls for non-running sessions', route.includes('page_ai_pi_lab_bridge_session_not_running') && route.includes('PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning')],
['route generates Pi extension with registered MNote tools', route.includes('ensure_session_tool_bridge_extension') && route.includes('pi.registerTool') && route.includes('mnote_current_page_read')],
['route starts Pi with explicit extension bridge', route.includes('--extension') && route.includes('mnoteToolBridgeExtension')],
['route starts Pi with MNote tool allowlist', route.includes('--tools') && route.includes('mnoteToolNames') && route.includes('pi_lab_extension_tool_names')],
['route keeps Pi builtin tools disabled while loading extension', route.includes('--no-builtin-tools') && route.includes('disabledBuiltinTools')],
['route applies TTL and rate limits', route.includes('PI_LAB_SESSION_TTL_MS') && route.includes('PI_LAB_RATE_LIMITS') && route.includes('check_rate_limit')],
['route cleans expired session dirs and rate buckets', route.includes('fs::remove_dir_all') && route.includes('buckets.retain')],
// === mod.rs checks ===
['mod.rs declares page_ai_pi module', routesMod.includes('mod page_ai_pi;')],
['mod.rs mounts pi status route', routesMod.includes('/api/page-ai/pi/status')],
['mod.rs mounts pi start route', routesMod.includes('/api/page-ai/pi/start')],
['mod.rs mounts pi send route', routesMod.includes('/api/page-ai/pi/send')],
['mod.rs mounts pi abort route', routesMod.includes('/api/page-ai/pi/abort')],
['mod.rs mounts pi events route', routesMod.includes('/api/page-ai/pi/events')],
['mod.rs mounts pi tool-call route', routesMod.includes('/api/page-ai/pi/tool-call')],
['mod.rs mounts pi internal tool-call-bridge route', routesMod.includes('/api/page-ai/pi/tool-call-bridge')],
['mod.rs mounts pi bootstrap route (legacy)', routesMod.includes('/api/page-ai/pi/bootstrap')],
['mod.rs mounts pi tool_call route', routesMod.includes('/api/page-ai/pi/tool-call')],
['mod.rs mounts pi lab runtime asset', routesMod.includes('sidebar-page-ai-pi-lab-runtime.js')],
// === web_shell.rs checks ===
['web_shell.rs has pi lab runtime asset function', webShell.includes('sidebar_page_ai_pi_lab_runtime_asset')],
['web_shell.rs includes pi lab runtime JS', webShell.includes('sidebar-page-ai-pi-lab-runtime.js')],
// === gateway.rs checks ===
['gateway.rs loads Pi Lab runtime when config enabled', gateway.includes('createSidebarPageAiPiLabRuntime')],
['gateway.rs does NOT stamp body hidden gate', !gateway.includes('data-page-ai-pi-lab-hidden')],
['app.rs defaults Pi Lab config on', app.includes('enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true)')],
// === Key architectural constraints ===
['no modification of sidebar-page-ai-runtime', !runtime.includes('sidebar-page-ai-runtime')],
['no sidebar-page-ai-runtime default behavior change',
!runtime.includes('sidebarPageAiRuntime')],
['no import of main page AI runtime', !runtime.includes('import.*sidebar-page-ai')],
];
const failed = checks.filter(([, ok]) => !ok);
if (failed.length) {
console.error('❌ Pi Lab static smoke failed:');
for (const [name] of failed) console.error(` - ${name}`);
process.exit(1);
}
console.log('✅ Pi Lab static smoke passed (' + checks.length + ' checks).');