From 896c94b696af8bca3fe98defaa2d9db2b00ca050 Mon Sep 17 00:00:00 2001 From: Agent Board Date: Fri, 10 Jul 2026 10:54:34 +0800 Subject: [PATCH] feat: integrate pi rust lab runtime --- .gitignore | 2 + .../5-44-block-menu-material-icons-leak-v1.md | 45 + ...8-browser-qa-workspace-sidebar-findings.md | 109 + ...47e9e34b0773-browser-qa-needs-more-info.md | 222 + ...4d52cca03e97-browser-qa-needs-more-info.md | 180 + .../done/7-73-page-ai-pi-ui-polish-v1.md | 40 + .../7-72-page-ai-pi-ui-completion-plan-v1.md | 378 ++ packages/pi-mnote/extensions/mnote-bridge.ts | 514 ++ .../extensions/mnote-mcp/.pi/mcp.json | 9 + .../pi-mnote/extensions/mnote-mcp/client.mjs | 521 ++ .../pi-mnote/extensions/mnote-mcp/index.ts | 154 + .../pi-rust-official/permission-gate.ts | 34 + .../pi-rust-official/plan-mode/README.md | 65 + .../pi-rust-official/plan-mode/index.ts | 340 ++ .../pi-rust-official/plan-mode/utils.ts | 168 + .../extensions/pi-rust-official/question.ts | 283 ++ .../pi-rust-official/questionnaire.ts | 453 ++ .../pi-rust-official/subagent/README.md | 172 + .../pi-rust-official/subagent/agents.ts | 127 + .../subagent/agents/planner.md | 37 + .../subagent/agents/reviewer.md | 35 + .../pi-rust-official/subagent/agents/scout.md | 50 + .../subagent/agents/worker.md | 24 + .../pi-rust-official/subagent/index.ts | 963 ++++ .../subagent/prompts/implement-and-review.md | 10 + .../subagent/prompts/implement.md | 10 + .../subagent/prompts/scout-and-plan.md | 9 + .../extensions/pi-rust-official/todo.ts | 299 ++ packages/pi-mnote/package.json | 25 + packages/pi-mnote/prompts/mnote-rag.md | 3 + packages/pi-mnote/skills/mnote/SKILL.md | 12 + .../browser/sidebar-page-ai-pi-lab-runtime.js | 3071 ++++++++++-- .../browser/sidebar-workspace-runtime.js | 58 +- .../mnote-web/src/routes/ai_settings.rs | 1199 ++++- rust/crates/mnote-web/src/routes/dev_seed.rs | 120 +- rust/crates/mnote-web/src/routes/gateway.rs | 129 +- .../mnote-web/src/routes/knowledge_rag.rs | 2 + .../src/routes/local_folder_source.rs | 552 ++- rust/crates/mnote-web/src/routes/mod.rs | 36 +- .../crates/mnote-web/src/routes/page_ai_pi.rs | 4367 ++++++++++++++++- rust/crates/mnote-web/src/routes/tree.rs | 64 + .../mnote-web/src/ssr/pages/ai_admin.rs | 978 +++- rust/crates/mnote-web/src/ssr/pages/layout.rs | 11 - rust/crates/mnote-web/src/ssr/styles/base.css | 99 +- .../mnote-leptos-tiptap-spike-island.d.ts | 40 +- .../mnote-leptos-tiptap-spike-island.js | 88 +- .../mnote-leptos-tiptap-spike-island_bg.wasm | Bin 2022544 -> 2147053 bytes ...te-leptos-tiptap-spike-island_bg.wasm.d.ts | 40 +- .../editor_runtime/block_handle_menu_view.rs | 19 +- .../src/editor_runtime/icons.rs | 15 +- .../src/editor_runtime/slash_menu_view.rs | 14 +- scripts/desktop-hot.js | 28 + scripts/dev-hot.js | 2 + scripts/lib/control-plane-dev-seed.js | 102 +- scripts/lib/pi-lab-warmup.js | 241 + scripts/qa-block-handle-menu-smoke.js | 439 ++ scripts/qa-block-handle-menu.js | 699 +++ ...i-management-control-plane-static-smoke.js | 35 +- scripts/task-pi-lab-api-endpoint-smoke.js | 24 +- scripts/task-pi-lab-browser-smoke.js | 118 +- .../task-pi-lab-full-access-ask-user-smoke.js | 356 ++ ...pi-lab-full-access-builtin-delete-smoke.js | 291 ++ scripts/task-pi-lab-input-controls-smoke.js | 521 ++ scripts/task-pi-lab-mock-api-smoke.js | 23 +- .../task-pi-lab-plan-mode-browser-smoke.js | 385 ++ scripts/task-pi-lab-real-lightrag-smoke.js | 319 ++ scripts/task-pi-lab-real-skill-mcp-smoke.js | 496 ++ scripts/task-pi-lab-rpc-api-smoke.js | 124 +- scripts/task-pi-lab-rpc-browser-smoke.js | 88 +- scripts/task-pi-lab-static-smoke.js | 66 +- scripts/task-pi-lab-ui-completion-smoke.js | 306 ++ .../task489-auth-profile-access-ui-smoke.js | 33 +- scripts/test-handle-qa.js | 113 + 73 files changed, 19852 insertions(+), 1152 deletions(-) create mode 100644 bugs/05-editor-mainline/done/5-44-block-menu-material-icons-leak-v1.md create mode 100644 bugs/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md create mode 100644 bugs/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md create mode 100644 bugs/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md create mode 100644 design/07-ai/done/7-73-page-ai-pi-ui-polish-v1.md create mode 100644 design/07-ai/process/7-72-page-ai-pi-ui-completion-plan-v1.md create mode 100644 packages/pi-mnote/extensions/mnote-bridge.ts create mode 100644 packages/pi-mnote/extensions/mnote-mcp/.pi/mcp.json create mode 100644 packages/pi-mnote/extensions/mnote-mcp/client.mjs create mode 100644 packages/pi-mnote/extensions/mnote-mcp/index.ts create mode 100644 packages/pi-mnote/extensions/pi-rust-official/permission-gate.ts create mode 100644 packages/pi-mnote/extensions/pi-rust-official/plan-mode/README.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/plan-mode/index.ts create mode 100644 packages/pi-mnote/extensions/pi-rust-official/plan-mode/utils.ts create mode 100644 packages/pi-mnote/extensions/pi-rust-official/question.ts create mode 100644 packages/pi-mnote/extensions/pi-rust-official/questionnaire.ts create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/README.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/agents.ts create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/agents/planner.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/agents/reviewer.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/agents/scout.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/agents/worker.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/index.ts create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/implement-and-review.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/implement.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/scout-and-plan.md create mode 100644 packages/pi-mnote/extensions/pi-rust-official/todo.ts create mode 100644 packages/pi-mnote/package.json create mode 100644 packages/pi-mnote/prompts/mnote-rag.md create mode 100644 packages/pi-mnote/skills/mnote/SKILL.md create mode 100644 scripts/lib/pi-lab-warmup.js create mode 100644 scripts/qa-block-handle-menu-smoke.js create mode 100644 scripts/qa-block-handle-menu.js create mode 100644 scripts/task-pi-lab-full-access-ask-user-smoke.js create mode 100644 scripts/task-pi-lab-full-access-builtin-delete-smoke.js create mode 100644 scripts/task-pi-lab-input-controls-smoke.js create mode 100644 scripts/task-pi-lab-plan-mode-browser-smoke.js create mode 100644 scripts/task-pi-lab-real-lightrag-smoke.js create mode 100644 scripts/task-pi-lab-real-skill-mcp-smoke.js create mode 100644 scripts/task-pi-lab-ui-completion-smoke.js create mode 100644 scripts/test-handle-qa.js diff --git a/.gitignore b/.gitignore index 06a60002..15daff5d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ env-archive/ /.gemini/ /.reasonix/ /.codegraph/ +/.pi/ +/tmp-block-handle-qa.js *.local tmp/ diff --git a/bugs/05-editor-mainline/done/5-44-block-menu-material-icons-leak-v1.md b/bugs/05-editor-mainline/done/5-44-block-menu-material-icons-leak-v1.md new file mode 100644 index 00000000..d7dfb0a4 --- /dev/null +++ b/bugs/05-editor-mainline/done/5-44-block-menu-material-icons-leak-v1.md @@ -0,0 +1,45 @@ +# 5-44 Block menu 打开后泄露 Material Symbols ligature 文本 + +> 创建时间:2026-07-08 +> 状态:`done` +> Owner:`05-editor-mainline` + +## 现象 + +在 mnote 主编辑器中 hover 到块手柄并点开块菜单后,菜单项图标位置直接显示 `auto_awesome`、`autorenew`、`chevron_right`、`content_copy`、`format_align_center` 等英文 ligature 文本,导致菜单宽度和排版明显错乱。 + +复现路径: + +1. 打开本地主编辑器文档。 +2. hover 到正文块左侧手柄。 +3. 点击手柄打开块菜单。 +4. 观察块菜单内图标列。 + +失败证据: + +- `tmp/block-handle-menu-display-diagnostic/after-click.png` +- `tmp/block-handle-menu-display-diagnostic/metrics.json` + +## 根因 + +主编辑器 island 的 icon helper 和部分动态菜单项只输出了 `class="material-symbols-outlined"` 加 ligature 文本,没有输出 `data-icon`。当前 mnote 主壳使用本地 SVG mask 作为 Material Symbols fallback,fallback CSS 只对带 `data-icon` 的节点生效;缺少 `data-icon` 时,浏览器没有 Material Symbols 字体可用,就直接把 ligature 文本当普通文本显示出来。 + +## 修复 + +- `material_icon()` 改为输出 `data-icon=`,并移除可见 ligature 文本。 +- block transform 子菜单和 slash 菜单动态图标同步输出 `data-icon`,不再把 ligature 作为可见文本。 +- 补齐主壳 CSS 中块菜单常用 icon 的本地 fallback,确保无外部 Material Symbols 字体时仍能显示为稳定图标。 +- 重新执行 wasm release build 与 `wasm-bindgen`,更新 `rust/spikes/leptos-tiptap-spike/generated/island/*` 产物。 + +## 验收 + +- `cargo check --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml` 通过,只有既有 warning。 +- `cargo build --manifest-path rust/spikes/leptos-tiptap-spike/Cargo.toml --target wasm32-unknown-unknown --release` 通过,只有既有 warning。 +- `wasm-bindgen rust/spikes/leptos-tiptap-spike/target/wasm32-unknown-unknown/release/mnote_leptos_tiptap_spike.wasm --target web --out-dir rust/spikes/leptos-tiptap-spike/generated/island --out-name mnote-leptos-tiptap-spike-island` 通过。 +- `node scripts/task489-block-menu-delete-undo-smoke.js` 通过。 +- 浏览器诊断确认块菜单 `leaked=false`,12 个菜单项正常渲染,图标节点宽高恢复为 `20x20`。 +- 修复后截图:`tmp/block-handle-menu-display-diagnostic/after-click-fixed.png`。 + +## 未覆盖 + +- 本轮只修复手柄菜单与 editor runtime 图标文本泄露;没有扩大到全站所有未带 `data-icon` 的历史图标节点。 diff --git a/bugs/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md b/bugs/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md new file mode 100644 index 00000000..6d72b7b9 --- /dev/null +++ b/bugs/05-editor-mainline/process/2026-07-08-browser-qa-workspace-sidebar-findings.md @@ -0,0 +1,109 @@ +# MNote 浏览器 QA 实测发现 2026-07-08 + +- **状态**:`process` +- **来源**:Agent Board Pi Taskflow 实测(MNote 浏览器 QA → Bug 文档 20260708-130503) +- **复现环境**: + - MNote Web:`http://127.0.0.1:3000`,Rust SSR + leptos-tiptap island + - 后端:control-plane libSQL local + - 浏览器:Playwright Chromium headless,viewport 1280×800 + - 登录:测试账号 `mnote.e2e@example.com`,authMode=controlPlaneSession + - 工作区:`/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space` + +--- + +## 1. 总体状态 + +| 检查项 | 结果 | 详情 | +|--------|------|------| +| API Gateway /health | ✅ PASS | 200 | +| Auth 页面加载 | ✅ PASS | 518ms | +| 测试账号快速登录 | ✅ PASS | 跳转到 `/` | +| Workspace 壳渲染 | ✅ PASS | sidebar / topbar / shell 均可见 | +| Page AI / OpenHub 页面 | ✅ PASS | 130ms 加载,AI 输入框可见 | +| Console 错误 | ✅ PASS | 0 errors | +| 业务网络错误 | ✅ PASS | 0 real errors(SSE abort 为预期导航行为) | + +--- + +## 2. 发现的问题 + +### 2.1 `file-order.json` 页面排序索引为空 + +- **严重程度**:medium +- **实际结果**:`/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space/.mnote/file-order.json` 内容为 `{"parents": {}, "version": 1}`,无任何页面条目。 +- **预期结果**:工作区中存在有效页面目录(`新页面054900`、`新页面145827`、`新页面233155`、`有机合成中的保护基` 等),每个目录下均有 `.md` 正文文件,file-order.json 应包含这些页面的排序条目。 +- **证据**: + ```bash + $ cat .mnote/file-order.json + {"parents": {}, "version": 1} + + $ ls workspace/ + 新页面054900/ 新页面145827/ 新页面233155/ 有机合成中的保护基/ + ``` +- **影响范围**:页面树(Page Tree)排序可能不正确,新建页面可能无法正确出现在侧边栏页面列表中。 +- **建议修复方向**:检查页面创建/重命名/移动流程是否正确更新 `file-order.json`;检查 Pi lab 批量创建页面的流程是否绕过了正常的 metadata 写入路径。 + +### 2.2 `/api/tree/page-tree` 端点返回 404 + +- **严重程度**:low +- **实际结果**:`GET /api/tree/page-tree` 返回 HTTP 404。 +- **预期结果**:应返回 200 或 401(需要认证),而非 404。 +- **证据**: + ```bash + $ curl -s -w "\nHTTP: %{http_code}" http://127.0.0.1:3000/api/tree/page-tree + HTTP: 404 + ``` +- **影响范围**:直接调用此 API 的外部客户端或测试脚本会失败。前端使用 `GET /api/tree/view-state?treeKind=pagetree`(需要认证,已正常工作),当前不受影响。 +- **建议修复方向**:确认 `/api/tree/page-tree` 是否为已废弃路由;若是,应在路由注册时返回 410 Gone 或转发到 view-state;若不是,应修复路由注册。 + +### 2.3 侧边栏中无 `a[href*="/document/"]` 格式的文档链接 + +- **严重程度**:low(待确认是否为预期行为) +- **实际结果**:Playwright selector `a[href*="/document/"]` 在侧边栏中找到 0 个元素。 +- **预期结果**:侧边栏页面树中的页面应可点击跳转到文档页。 +- **证据**:QA 脚本 `page.locator('a[href*="/document/"]').count()` 返回 0。 +- **影响范围**:如果页面确实使用不同 URL 模式(如 `/page/...` 或前端路由),则无影响;否则侧边栏页面无法点击跳转。 +- **建议修复方向**:确认当前侧边栏页面树使用的链接格式;如果是前端 SPA 路由(如 `data-page-id` + click handler),则更新测试 selector;如果是 bug,修复链接生成。 + +### 2.4 Dev 模式 hot-reload 轮询累积 + +- **严重程度**:low(仅 dev 模式) +- **实际结果**:多个 `GET /api/dev/hot-reload` 请求处于 pending 状态,随时间累积。 +- **预期结果**:hot-reload 连接应复用或及时关闭,不应无限累积。 +- **证据**:网络追踪显示 20+ 个 pending 的 `GET /api/dev/hot-reload` 请求。 +- **影响范围**:仅影响开发模式下的浏览器性能和内存占用,不影响生产环境。 +- **建议修复方向**:检查 hot-reload EventSource/WebSocket 连接的生命周期管理,确保旧连接在页面导航或重连时正确关闭。 + +--- + +## 3. 已验证无问题的路径 + +- Auth 登录流程:正常 +- Workspace 壳渲染:正常 +- Page AI / OpenHub 页面:正常加载 +- API `/api/auth/session`:正常 +- API `/api/tree/view-state`(需认证):正常 +- API `/api/tree/projections/file/children`(需认证):正常 +- SSE `/api/local-folder/events`:正常建立连接 +- API `/api/page-ai/pi/status`:正常 +- API `/api/ai-settings/effective`:正常 + +--- + +## 4. 测试脚本 + +QA 脚本位于: +- `/tmp/mnote-browser-qa-20260708.js`(基础检查) +- `/tmp/mnote-browser-qa-20260708-detail.js`(详细检查) +- `/tmp/mnote-browser-qa-comprehensive.js`(综合检查) +- `/tmp/mnote-network-trace.js`(网络追踪) + +结果文件: +- `/tmp/mnote-browser-qa-20260708/result.json` +- `/tmp/mnote-browser-qa-20260708/result-detail.json` +- `/tmp/mnote-browser-qa-20260708/result-final.json` + +截图: +- `/tmp/mnote-browser-qa-20260708/01-main-page.png`(Auth 页) +- `/tmp/mnote-browser-qa-20260708/02-after-login.png`(登录后 workspace) +- `/tmp/mnote-browser-qa-20260708/05-post-login-workspace.png`(workspace 页) diff --git a/bugs/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md b/bugs/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md new file mode 100644 index 00000000..1a1bc5d6 --- /dev/null +++ b/bugs/board-workflow/d7585a30-63cd-4201-a3c5-47e9e34b0773-browser-qa-needs-more-info.md @@ -0,0 +1,222 @@ +# Browser QA needs-more-info + +- workflow_run_id: d7585a30-63cd-4201-a3c5-47e9e34b0773 +- status: needs-more-info +- triage: NEEDS_MORE_QA + +## QA Scope + +MINIMAL_BROWSER_QA_RUNBOOK +TARGET: 主编辑器段落块左侧手柄 hover/点击/菜单显示与图标布局 +URL: http://localhost:3000 +ACCOUNT: 测试账号快速登录 (mnote.e2e@example.com / MnoteE2E123!) +PAGE: 打开任意包含正文段落块的页面(如首页/快速入门) +TARGET_BLOCK: 第一个正文段落块(普通文本段落) + +STEPS (≤6): +1. 登录并进入工作区 +2. 点击侧边栏任意页面,打开包含正文段落块的文档 +3. 鼠标悬停在第一个段落块左侧,观察手柄是否出现 +4. 点击手柄,观察块菜单是否弹出 +5. 检查菜单布局与图标(6点/拖拽、删除、转换等图标)是否正常 +6. 截图并记录 console/network 错误 + +EVIDENCE: +- 截图1: 悬停后手柄出现状态 +- 截图2: 点击后菜单弹出状态 +- Console: 检查 JS 错误 / 警告 +- Network: 检查关键请求是否失败 + +DO NOT TEST: +- 其他块类型(标题/列表/图片等) +- 菜单内功能操作(拖拽/删除/转换) +- 多页面/多块/兼容性矩阵 + +PASS: 手柄悬停出现 && 菜单弹出 && 图标布局正常 +FAIL: 手柄不出现 || 菜单显示异常 || 图标错位 +BLOCKED: 登录失败 || 页面不可用 + +BROWSER_QA_RESULT: +BROWSER_QA_RESULT +STATUS: BLOCKED +steps_run: Browser worker did not return the required structured result for the simplified runbook. +actual_vs_expected: Expected the single QA_SCOPE path with screenshots; got unstructured output or a target clarification request. +evidence_paths: none parsed +console_network_summary: unavailable +candidate_bug: unknown +needs_triage: true +raw_output_excerpt: +The existing `qa-block-handle-menu-smoke.js` perfectly matches this runbook's scope. Let me run it directly.Good — `qa-block-handle-menu-smoke.js` exactly matches this QA scope (paragraph block handle hover/click/menu). Running it now.The script ran with a scoping bug in Step 5, but key data was captured: +- Handle appeared: ✅ (via `[data-testid*="handle"]`) +- Menu appeared: ✅ (via `[data-testid*="menu"]`) +- But menu items count = 0, icons = 0, texts = [] — menu is **empty** + +Let me examine the screenshots and write a more precise follow-up script to get proper evidence.Let me analyze the screenshots. These show the Wolai mindmap embed within a page. Let me read the skill and gather context before proceeding.Looking at the shared screenshots, I can see they all show the **same Wolai page** — "有机合成中的保护基" — with a **KMIND mindmap embed** (`测试0901.kmind`). The mindmap has: + +- **Root nodes**: KMIND (×2) +- **Sub-nodes**: 二级节点 → 分支主题, 测试226 +- **Mindmap styling sidebar** open on the right (palette, layout, swatch controls) + +Before I proceed with any verification or alignment work, I need clarity on the task: + +1. **Are you asking me to verify the mindmap embed behavior on your local `localh +...[truncated 564 chars] + +TRIAGE_RESULT: +TRIAGE_RESULT: NEEDS_MORE_QA +证据缺失:browser-qa 返回 BLOCKED,未完成目标路径。 +判断理由:阻塞属于 QA 执行问题,不能判定产品 bug。 +最小修复边界:N/A +回归路径:先解决登录、选择器或页面状态阻塞,再按 QA_SCOPE 复测。 +bug 文档字段:status=needs-more-info;记录 blocked reason。 + +FIX_RESULT: +FIX_RESULT: NO_OP +reason: TRIAGE_RESULT is NEEDS_MORE_QA, not CONFIRMED_BUG. +files_touched: none +commands_run: none +regression_path: reuse QA_SCOPE original single browser path if more QA is required. + +REGRESSION_QA_RESULT: +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## Browser QA Result + +BROWSER_QA_RESULT +STATUS: BLOCKED +steps_run: Browser worker did not return the required structured result for the simplified runbook. +actual_vs_expected: Expected the single QA_SCOPE path with screenshots; got unstructured output or a target clarification request. +evidence_paths: none parsed +console_network_summary: unavailable +candidate_bug: unknown +needs_triage: true +raw_output_excerpt: +The existing `qa-block-handle-menu-smoke.js` perfectly matches this runbook's scope. Let me run it directly.Good — `qa-block-handle-menu-smoke.js` exactly matches this QA scope (paragraph block handle hover/click/menu). Running it now.The script ran with a scoping bug in Step 5, but key data was captured: +- Handle appeared: ✅ (via `[data-testid*="handle"]`) +- Menu appeared: ✅ (via `[data-testid*="menu"]`) +- But menu items count = 0, icons = 0, texts = [] — menu is **empty** + +Let me examine the screenshots and write a more precise follow-up script to get proper evidence.Let me analyze the screenshots. These show the Wolai mindmap embed within a page. Let me read the skill and gather context before proceeding.Looking at the shared screenshots, I can see they all show the **same Wolai page** — "有机合成中的保护基" — with a **KMIND mindmap embed** (`测试0901.kmind`). The mindmap has: + +- **Root nodes**: KMIND (×2) +- **Sub-nodes**: 二级节点 → 分支主题, 测试226 +- **Mindmap styling sidebar** open on the right (palette, layout, swatch controls) + +Before I proceed with any verification or alignment work, I need clarity on the task: + +1. **Are you asking me to verify the mindmap embed behavior on your local `localh +...[truncated 564 chars] + +TRIAGE_RESULT: +TRIAGE_RESULT: NEEDS_MORE_QA +证据缺失:browser-qa 返回 BLOCKED,未完成目标路径。 +判断理由:阻塞属于 QA 执行问题,不能判定产品 bug。 +最小修复边界:N/A +回归路径:先解决登录、选择器或页面状态阻塞,再按 QA_SCOPE 复测。 +bug 文档字段:status=needs-more-info;记录 blocked reason。 + +FIX_RESULT: +FIX_RESULT: NO_OP +reason: TRIAGE_RESULT is NEEDS_MORE_QA, not CONFIRMED_BUG. +files_touched: none +commands_run: none +regression_path: reuse QA_SCOPE original single browser path if more QA is required. + +REGRESSION_QA_RESULT: +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## Triage Result + +NEEDS_MORE_QA +证据缺失:browser-qa 返回 BLOCKED,未完成目标路径。 +判断理由:阻塞属于 QA 执行问题,不能判定产品 bug。 +最小修复边界:N/A +回归路径:先解决登录、选择器或页面状态阻塞,再按 QA_SCOPE 复测。 +bug 文档字段:status=needs-more-info;记录 blocked reason。 + +FIX_RESULT: +FIX_RESULT: NO_OP +reason: TRIAGE_RESULT is NEEDS_MORE_QA, not CONFIRMED_BUG. +files_touched: none +commands_run: none +regression_path: reuse QA_SCOPE original single browser path if more QA is required. + +REGRESSION_QA_RESULT: +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## Fix Result + +NO_OP +reason: TRIAGE_RESULT is NEEDS_MORE_QA, not CONFIRMED_BUG. +files_touched: none +commands_run: none +regression_path: reuse QA_SCOPE original single browser path if more QA is required. + +REGRESSION_QA_RESULT: +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## Regression QA Result + +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## QA Acceptance + +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. diff --git a/bugs/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md b/bugs/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md new file mode 100644 index 00000000..98928da0 --- /dev/null +++ b/bugs/board-workflow/fcaefa85-5cb9-45d6-b238-4d52cca03e97-browser-qa-needs-more-info.md @@ -0,0 +1,180 @@ +# Browser QA needs-more-info + +- workflow_run_id: fcaefa85-5cb9-45d6-b238-4d52cca03e97 +- status: needs-more-info +- triage: NEEDS_MORE_QA + +## QA Scope + +MINIMAL_BROWSER_QA_RUNBOOK +- Target URL: http://localhost:3000 +- Login: click "测试账号快速登录" (mnote.e2e@example.com) +- Target page: 任意已存在或新建的文档页,含至少一个正文段落块(非标题、非列表、非代码块) +- Target block: 该页面中第一个普通段落块 +- Operations (max 6): + 1. 打开页面,等待编辑器加载完成 + 2. 鼠标悬停(hover)在该段落块左侧空白区域,等待手柄出现 + 3. 截图1:手柄出现时的整体布局(包含段落块、手柄图标、周围元素) + 4. 点击该手柄 + 5. 等待块菜单弹出,不移动鼠标 + 6. 截图2:块菜单弹出后的完整布局(含菜单所有图标、文字、与段落块的位置关系) +- Console/Network: 截图前打开 DevTools Console,记录所有 error/warning;检查 Network 中是否有 4xx/5xx 请求 +- Required screenshots: 2(hover 手柄出现 + 点击后菜单弹出) +- Exclusions: 不测试其他块类型(标题、列表、代码块、图片等);不测试拖拽、删除、复制等菜单操作;不测试其他页面;不测试移动端;不测试 theme 切换 +- PASS/FAIL/BLOCKED: + PASS: 手柄 hover 后正常出现,点击后菜单弹出且菜单图标/文字完整可见、无重叠、无截断,无 console error 且无 4xx/5xx + FAIL: 手柄未出现、菜单未弹出、菜单布局错乱(图标缺失、重叠、截断、位置偏移严重)、出现 console error 或 4xx/5xx + BLOCKED: 页面无法加载、编辑器未渲染、无法登录、段落块不存在 + +BROWSER_QA_RESULT: +BROWSER_QA_RESULT +STATUS: BLOCKED +steps_run: phase boundary failed before structured evidence could be collected. +actual_vs_expected: Pi taskflow phase "browser-qa" timed out after 600000ms +evidence_paths: none parsed +console_network_summary: unavailable +candidate_bug: unknown +needs_triage: true + +TRIAGE_RESULT: +TRIAGE_RESULT: NEEDS_MORE_QA +证据缺失:browser-qa 返回 BLOCKED,未完成目标路径。 +判断理由:阻塞属于 QA 执行问题,不能判定产品 bug。 +最小修复边界:N/A +回归路径:先解决登录、选择器或页面状态阻塞,再按 QA_SCOPE 复测。 +bug 文档字段:status=needs-more-info;记录 blocked reason。 + +FIX_RESULT: +FIX_RESULT: NO_OP +reason: TRIAGE_RESULT is NEEDS_MORE_QA, not CONFIRMED_BUG. +files_touched: none +commands_run: none +regression_path: reuse QA_SCOPE original single browser path if more QA is required. + +REGRESSION_QA_RESULT: +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## Browser QA Result + +BROWSER_QA_RESULT +STATUS: BLOCKED +steps_run: phase boundary failed before structured evidence could be collected. +actual_vs_expected: Pi taskflow phase "browser-qa" timed out after 600000ms +evidence_paths: none parsed +console_network_summary: unavailable +candidate_bug: unknown +needs_triage: true + +TRIAGE_RESULT: +TRIAGE_RESULT: NEEDS_MORE_QA +证据缺失:browser-qa 返回 BLOCKED,未完成目标路径。 +判断理由:阻塞属于 QA 执行问题,不能判定产品 bug。 +最小修复边界:N/A +回归路径:先解决登录、选择器或页面状态阻塞,再按 QA_SCOPE 复测。 +bug 文档字段:status=needs-more-info;记录 blocked reason。 + +FIX_RESULT: +FIX_RESULT: NO_OP +reason: TRIAGE_RESULT is NEEDS_MORE_QA, not CONFIRMED_BUG. +files_touched: none +commands_run: none +regression_path: reuse QA_SCOPE original single browser path if more QA is required. + +REGRESSION_QA_RESULT: +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## Triage Result + +NEEDS_MORE_QA +证据缺失:browser-qa 返回 BLOCKED,未完成目标路径。 +判断理由:阻塞属于 QA 执行问题,不能判定产品 bug。 +最小修复边界:N/A +回归路径:先解决登录、选择器或页面状态阻塞,再按 QA_SCOPE 复测。 +bug 文档字段:status=needs-more-info;记录 blocked reason。 + +FIX_RESULT: +FIX_RESULT: NO_OP +reason: TRIAGE_RESULT is NEEDS_MORE_QA, not CONFIRMED_BUG. +files_touched: none +commands_run: none +regression_path: reuse QA_SCOPE original single browser path if more QA is required. + +REGRESSION_QA_RESULT: +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## Fix Result + +NO_OP +reason: TRIAGE_RESULT is NEEDS_MORE_QA, not CONFIRMED_BUG. +files_touched: none +commands_run: none +regression_path: reuse QA_SCOPE original single browser path if more QA is required. + +REGRESSION_QA_RESULT: +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## Regression QA Result + +REGRESSION_QA_RESULT +STATUS: PASS +steps_run: skipped regression because bug-triage returned NEEDS_MORE_QA, not CONFIRMED_BUG. +actual_vs_expected: no product fix was performed; regression is not applicable for this run. +evidence_paths: inherited from BROWSER_QA_RESULT +console_network_summary: inherited from BROWSER_QA_RESULT + +QA_ACCEPTANCE: +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. + +## QA Acceptance + +VERDICT: PASS +reason: no product bug confirmed; close this run as needs-more-info instead of looping fix/regression. browserStatus=BLOCKED, regressionStatus=PASS. +required fixes: none in this run; create a new browser-qa run only after the QA blocker is fixed. +Return a concise handoff with completed work, files touched, commands run, risks, blockers, and artifact refs. diff --git a/design/07-ai/done/7-73-page-ai-pi-ui-polish-v1.md b/design/07-ai/done/7-73-page-ai-pi-ui-polish-v1.md new file mode 100644 index 00000000..1e8f6d00 --- /dev/null +++ b/design/07-ai/done/7-73-page-ai-pi-ui-polish-v1.md @@ -0,0 +1,40 @@ +# 7-73 Page AI Pi UI 产品化收口 v1 + +状态:done +Owner:07-ai / mnote-web / browser runtime +日期:2026-07-05 + +## 目标 + +在 `7-72` 功能补齐后,先收口用户可见的 Pi 面板体验:历史记录不能遮住输入区,顶部工具栏使用一致图标,辅助浮层不再像调试面板一样挤占主对话区。 + +## 已完成 + +- [x] History 从文档流面板改为 Pi 内部浮层,固定在顶部工具栏下方,不再把消息区和 composer 顶乱。 +- [x] History 浮层增加标题栏与关闭按钮,打开 History 时自动关闭 Artifacts 浮层。 +- [x] 顶部 commandbar 改为统一 SVG icon button,覆盖新对话、历史、打开、Artifacts、运行状态、通知、启动、中止、清空、设置。 +- [x] 底部 modebar 去掉临时 `Build / Plan` 分段,保留真正有用的 send mode、模型、队列和快捷操作。 +- [x] 收紧 commandbar、消息区、composer 的间距、圆角和阴影,弱化调试感。 +- [x] 正式 smoke 增加 UI polish 断言:History 不遮 composer、顶部工具栏图标数量、History 操作入口。 + +## 验证 + +- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js` +- `node --check scripts/task-pi-lab-ui-completion-smoke.js` +- `cargo test -p mnote-web page_ai_pi --lib`,10 passed +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task-pi-lab-ui-completion-smoke.js` + +真实浏览器证据: + +- 结果:`/tmp/mnote-pi-ui-completion-1783248311039/result.json` +- History 浮层与统一图标:`/tmp/mnote-pi-ui-completion-1783248311039/01-history-session-actions.png` +- 审批 audit/receipt:`/tmp/mnote-pi-ui-completion-1783248311039/02-approval-audit-ui.png` +- Abort stopReason / queued:`/tmp/mnote-pi-ui-completion-1783248311039/03-abort-stopreason-queue.png` + +关键断言: + +- `historyDoesNotCoverComposer=true` +- `commandbarIconCount=10` +- `hasHistoryOpen=true` +- `hasHistoryRename=true` +- `hasHistoryFork=true` diff --git a/design/07-ai/process/7-72-page-ai-pi-ui-completion-plan-v1.md b/design/07-ai/process/7-72-page-ai-pi-ui-completion-plan-v1.md new file mode 100644 index 00000000..4a0c1b85 --- /dev/null +++ b/design/07-ai/process/7-72-page-ai-pi-ui-completion-plan-v1.md @@ -0,0 +1,378 @@ +# 7-72 Page AI Pi UI 补全方案 v1 + +状态:process +Owner:07-ai / mnote-web / browser runtime +日期:2026-07-05 + +## 1. 结论 + +当前 Pi Lab 已能通过真实 `desktop:hot` 链路调用 Skill 与 MCP,但 UI 只完成了最基础的聊天和 Markdown 渲染。它还没有达到 Pi 官方 TUI / OpenClaw 类 Web UI 的可用体验基线。 + +本阶段不应该继续只补零散样式。应按 Pi 事件模型重做一层 `PiRunViewModel`,把 `message_update`、`tool_execution_*`、`queue_update`、`abort`、extension UI dialog、notification、receipt、artifact 全部归一成浏览器可渲染的运行时间线。 + +真实验证证据: + +- 清理后 Skill/MCP 配置截图:`/tmp/mnote-pi-clean-after-fix-1783222211192/01-admin-ai-skills-after-clean.png` +- 当前 UI 初始状态:`/tmp/mnote-pi-ui-gap-audit-1783222292502/01-current-pi-initial.png` +- 当前 UI streaming 状态:`/tmp/mnote-pi-ui-gap-audit-1783222292502/03-current-pi-streaming-early.png` +- 当前 UI 回复后状态:`/tmp/mnote-pi-ui-gap-audit-1783222292502/04-current-pi-after-reply.png` +- 清理后真实 Skill/MCP 链路:`/tmp/mnote-pi-real-after-clean-1783222483/result.json` + +## 2. 已清理的问题 + +重复配置已清理: + +- 删除空 source Skill:`mnote-browser-smoke-*` +- 删除临时 Skill:`mnote-pi-skill-proof` +- 删除重复 LightRAG smoke MCP:`lightrag-smoke-*` + +清理后有效配置: + +- Skills:`Chrome Bridge`、`CodeGraph`、`Context7`、`Global Search`、`MemPalace`、`SearXNG Search`、`VPN` +- MCP:`Chrome Bridge`、`CodeGraph`、`Context7`、`MemPalace`、`SearXNG` +- `emptySourceSkills=[]` +- `duplicateMcpEndpoints=[]` + +同时修复了 `/api/ai-admin/settings` 的删除语义:`skills` 与 `mcpServers` 在 admin 编辑中代表完整目标 registry,不再把旧配置 merge 回来导致删除无效。 + +## 3. 对照基线 + +Pi 官方能力基线: + +- RPC prompt streaming 中必须支持 `streamingBehavior: "steer"` 与 `"followUp"`;不传会在 streaming 中报错。 +- RPC 有独立 `steer`、`follow_up`、`abort` 命令。 +- JSON event stream 暴露 `queue_update`、`message_update`、`tool_execution_start`、`tool_execution_update`、`tool_execution_end`、`compaction_*`。 +- TUI 中 Enter 是 steer,Alt+Enter 是 follow-up,Escape 是 abort,Alt+Up 可取回排队消息。 +- TUI Markdown 不是简单 HTML 转换,而是处理 streaming partial fence、thinking block、宽度缓存、主题。 +- extension UI 有 `ctx.ui.select()`、`ctx.ui.confirm()`、`ctx.ui.input()`、`ctx.ui.notify()`,并支持 AbortSignal 关闭。 + +OpenClaw / 第三方 Web UI 可参考: + +- `openclaw/ui/src/ui/chat/tool-cards.ts`:工具卡片默认折叠,展开显示 input/output/raw details/error/sidebar。 +- `openclaw/ui/src/ui/chat/grouped-render.ts`:消息分组、Markdown sanitation、thinking block、copy-as-markdown。 +- `openclaw/ui/src/ui/components/modal-dialog.ts`:标准 modal dialog + backdrop + focus trap。 +- `openclaw/extensions/qa-lab/web/src/ui-render.ts`:长任务 runner、artifact、evidence、capture timeline 的信息架构。 + +## 4. 当前 UI 缺口 + +真实浏览器审计结果: + +- 有 streaming 状态与中止按钮,但 streaming 中输入框没有 steer / follow-up 模式,无法追加或插队。 +- 有 tool receipt 折叠区,但没有按 turn 内时间线显示工具调用开始、参数增量、结果、错误、耗时。 +- 工具调用过程没有和 assistant message 绑定;用户看到的是“模型说自己调用了工具”,不是 UI 明确展示调用过程。 +- 没有 extension UI dialog。审计结果 `hasDialog=false`。 +- 没有独立通知/Toast。当前“通知”只是一个静态按钮,不是 `ctx.ui.notify()` 的承载面。 +- Markdown 渲染已存在,但缺 thinking block、copy-as-markdown、streaming partial fence 处理、表格/代码块稳定布局。 +- session history 只是列表入口,缺少 resume/fork/tree entry 级别视图。 +- abort 只可点一次停止,没有显示 abort response、aborted stopReason、被恢复的 queued messages。 +- 没有 pending queue 可视化。审计结果 `hasFollowupOrSteerControl=false`。 +- 没有 artifact / citation / diff right rail;LightRAG、file patch、reference.open 只能混在正文或 receipt 里。 + +## 5. 目标 UI 信息架构 + +Pi Lab 主体分四层: + +1. 顶部运行栏 + - session name / model / thinking / context usage / runtime pid / allowed roots badge + - streaming、queued、aborted、error 明确状态 + - stop、new、history、settings 以 icon button 呈现 + +2. 消息流 + - user / assistant / system 分组 + - assistant 支持 live Markdown、thinking 折叠、copy-as-markdown + - 每个 turn 下嵌入 tool timeline,而不是只靠 receipt 区 + +3. Composer + - idle:普通发送 + - streaming:显示 segmented control:`Steer` / `Follow-up` + - Stop 后恢复未发送/排队文本 + - 支持图片/附件占位,但第一阶段不实现上传 + +4. 右侧/底部详情 rail + - Tool details:参数、结果、raw JSON、stderr、耗时、receipt id + - Artifacts:diff、citation、open-reference、文件变更 + - Diagnostics:RPC events、session jsonl、policy snapshot,仅开发态默认折叠 + +## 6. 分阶段实施 + +## 6.0 执行 Checklist + +### 已完成 + +- [x] 完成 Pi 官方 RPC / JSON event / TUI / extension UI 对照,确认 UI 缺口不只是 Markdown 渲染。 +- [x] 完成当前 `/page-ai/pi` 真实浏览器 UI 缺口审计,证据:`/tmp/mnote-pi-ui-gap-audit-1783222292502/result.json`。 +- [x] 清理重复 Skill/MCP 配置,并修复 admin 设置删除后被 merge 回来的问题。 +- [x] 验证清理后真实 Skill/MCP 链路仍可用,证据:`/tmp/mnote-pi-real-after-clean-1783222483/result.json`。 +- [x] 确认后端 `/api/page-ai/pi/send` 已支持 `streamingBehavior`,前端原先未传递。 +- [x] 在 Pi Lab 消息流内新增折叠工具时间线与工具卡,支持 `tool_execution_*`、`toolcall_*`、后端 tool receipt 统一展示。 +- [x] 工具时间线过滤 thinking/reasoning 内容,避免把模型思考块作为工具详情泄漏到 UI。 +- [x] 在 composer 中新增 `Steer / Follow-up` 控件,streaming 中允许继续输入并发送 `streamingBehavior`。 +- [x] Stop/abort 改为结构化 aborted 状态,不再把“已中止”拼进正常 Markdown 正文。 +- [x] 新增 Phase 1 浏览器 smoke:`tmp/mnote-pi-ui-phase1-smoke.js`。 +- [x] 使用 `npm run desktop:hot` 真启动环境完成 `/page-ai/pi` 浏览器验证,证据:`/tmp/mnote-pi-ui-phase1-1783228146571/result.json`。 +- [x] 验证截图已产出: + - [x] 初始态:`/tmp/mnote-pi-ui-phase1-1783228146571/01-initial.png` + - [x] Steer/Follow-up 控件:`/tmp/mnote-pi-ui-phase1-1783228146571/02-started-with-steer-controls.png` + - [x] streaming 中止按钮:`/tmp/mnote-pi-ui-phase1-1783228146571/03-streaming-stop-visible.png` + - [x] 工具时间线展开:`/tmp/mnote-pi-ui-phase1-1783228146571/04-tool-timeline-expanded-followup.png` +- [x] Phase 1 smoke 断言通过: + - [x] `hasToolTimeline=true` + - [x] `toolCardCount=2` + - [x] `hasFollowupControls=true` + - [x] `hasQueueControls=true` + - [x] `streamingBehaviorSent=true` + - [x] `noReasoningLeakInToolTimeline=true` +- [x] 运行 `node --check rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js` 通过。 +- [x] 运行 `node --check tmp/mnote-pi-ui-phase1-smoke.js` 通过。 +- [x] 运行 `cargo test -p mnote-web page_ai_pi --lib` 通过,10 tests passed。 +- [x] 运行 `codegraph sync . && codegraph status .`,索引 up to date。 +- [x] 处理 Pi JSONL `message` 事件中的 assistant `toolCall` 与 `toolResult`,MCP 不再只能靠正文证明。 +- [x] MCP 工具卡标题已规范化为 `mcp:/`,例如 `mcp:context7/list`、`mcp:mempalace/mempalace_mempalace_search`。 +- [x] 新增真实 MCP 工具卡 smoke:`tmp/mnote-pi-mcp-toolcards-smoke.js`。 +- [x] 使用 `npm run desktop:hot` 真启动环境完成 Context7 / MemPalace MCP 工具卡验证,证据:`/tmp/mnote-pi-mcp-toolcards-1783230894265/result.json`。 +- [x] MCP 工具卡截图已产出: + - [x] Context7:`/tmp/mnote-pi-mcp-toolcards-1783230894265/02-context7-mcp-toolcards.png` + - [x] MemPalace:`/tmp/mnote-pi-mcp-toolcards-1783230894265/03-mempalace-mcp-toolcards.png` +- [x] MCP 工具卡 smoke 断言通过: + - [x] `hasContext7ToolCard=true` + - [x] `hasMemPalaceToolCard=true` + - [x] `context7McpCalled=true` + - [x] `mempalaceMcpCalled=true` + - [x] `noReasoningLeakInToolTimeline=true` +- [x] 新增 `PiRunViewModel` reducer,统一承接 user prompt、assistant streaming/done/abort、tool upsert、citation、diff、queue_update、clear。 +- [x] 新增 reducer 单元测试:`tmp/mnote-pi-run-viewmodel-reducer-test.js`。 +- [x] reducer 测试通过:`node tmp/mnote-pi-run-viewmodel-reducer-test.js`。 +- [x] Phase 1 smoke 支持桌面/移动 viewport 参数,工具卡可见性断言改为真实可见节点,避免折叠历史 timeline 误判。 +- [x] 使用当前重启后的 `npm run desktop:hot` 完成桌面真实浏览器验证,证据:`/tmp/mnote-pi-ui-phase1-1783234193679/result.json`。 +- [x] 桌面截图已产出:`/tmp/mnote-pi-ui-phase1-1783234193679/04-tool-timeline-expanded-followup.png`。 +- [x] 使用当前重启后的 `npm run desktop:hot` 完成移动 viewport 真实浏览器验证,证据:`/tmp/mnote-pi-ui-phase1-1783234215942/result.json`。 +- [x] 移动截图已产出:`/tmp/mnote-pi-ui-phase1-1783234215942/04-tool-timeline-expanded-followup.png`。 +- [x] 移动 composer 控件补齐响应式换行,`Steer / Follow-up / queue / model` 不再挤出右侧边界。 +- [x] 使用当前重启后的 `npm run desktop:hot` 完成 Context7 / MemPalace MCP 工具卡复验,证据:`/tmp/mnote-pi-mcp-toolcards-1783234246744/result.json`。 +- [x] 历史 session replay 改为复用 Pi RPC 事件处理链与 `PiRunViewModel`,不再只把 text delta 渲染成简化消息。 +- [x] `queue_update` 在 live UI 和 history replay 中都进入同一个 reducer 状态。 +- [x] Phase 1 浏览器 smoke 增加 history replay 断言与截图,证据:`/tmp/mnote-pi-ui-phase1-1783240450668/result.json`。 +- [x] History replay 截图已产出:`/tmp/mnote-pi-ui-phase1-1783240450668/05-history-replay-tool-timeline.png`。 +- [x] 使用 Context7 核对 Pi 官方 extension UI RPC:`extension_ui_request` / `extension_ui_response`,方法包括 `confirm/select/input/editor/notify`。 +- [x] 后端新增 `/api/page-ai/pi/ui-response`,把浏览器 dialog 结果写回 Pi RPC stdin,并持久化 `extension_ui_response` 事件。 +- [x] 前端新增 Extension UI modal host,支持 `confirm/select/input/editor`,并支持 timeout/cancel。 +- [x] 前端新增 `ctx.ui.notify()` toast stack,支持 `info/warning/error/success` 风格。 +- [x] 新增 extension UI 浏览器 smoke:`tmp/mnote-pi-extension-ui-smoke.js`。 +- [x] 使用 `npm run desktop:hot` 真启动环境完成 Extension UI 浏览器验证,证据:`/tmp/mnote-pi-extension-ui-1783241631879/result.json`。 +- [x] Extension UI 截图已产出: + - [x] Confirm dialog:`/tmp/mnote-pi-extension-ui-1783241631879/01-confirm-dialog.png` + - [x] Select dialog:`/tmp/mnote-pi-extension-ui-1783241631879/02-select-dialog.png` + - [x] Input dialog:`/tmp/mnote-pi-extension-ui-1783241631879/03-input-dialog.png` + - [x] Notify toast:`/tmp/mnote-pi-extension-ui-1783241631879/04-notify-toast.png` +- [x] Extension UI 弹窗位置已按 Pi UI 习惯改为 Pi 面板内部、输入框上方弹出,不再使用全系统居中遮罩。复验证据:`/tmp/mnote-pi-extension-ui-1783242363461/result.json`。 +- [x] Pi 内嵌弹窗/通知截图已产出: + - [x] Confirm dialog:`/tmp/mnote-pi-extension-ui-1783242363461/01-confirm-dialog.png` + - [x] Notify toast:`/tmp/mnote-pi-extension-ui-1783242363461/04-notify-toast.png` +- [x] Extension UI 弹窗底边已对齐到 Pi 输入区上边界,不再遮住输入框。复验证据:`/tmp/mnote-pi-extension-ui-1783242834885/result.json`。 +- [x] 对齐后截图:`/tmp/mnote-pi-extension-ui-1783242834885/01-confirm-dialog.png`。 +- [x] Artifact / Diff / Citation rail 初步完成,消息状态中的 citation、diff、MCP tool result 会进入右侧 Artifacts。 +- [x] Artifact rail 支持 citation 打开引用入口、MCP raw 复制、artifact Markdown 复制,assistant 消息支持 copy-as-markdown。 +- [x] 工具详情已改为默认压缩摘要,Input / Output / Raw 详情折叠显示,避免长 MCP 结果默认占满消息区。 +- [x] Receipts rail 增加打开当前 session JSONL 的入口,使用当前 Pi session events 生成 NDJSON。 +- [x] Artifact rail 浏览器 smoke 已覆盖 citation/diff/MCP/copy/open-session-jsonl/折叠详情断言,证据:`/tmp/mnote-pi-artifact-rail-1783244314915/result.json`。 +- [x] Artifact rail 截图已产出: + - [x] rail 与工具摘要:`/tmp/mnote-pi-artifact-rail-1783244314915/01-artifact-rail.png` + - [x] session JSONL 入口:`/tmp/mnote-pi-artifact-rail-1783244314915/02-session-jsonl-action.png` +- [x] Artifacts 不再默认常驻右栏占用 Pi 主界面,改为 commandbar 图标 + 数量 badge,点击后作为 Pi 内部浮层展开。 +- [x] Artifacts 浮层底部停在 composer 上方,不遮住输入框。复验证据:`/tmp/mnote-pi-artifact-rail-1783245137938/result.json`。 +- [x] Artifacts 默认折叠/按需展开截图已产出: + - [x] 默认折叠,仅显示 badge:`/tmp/mnote-pi-artifact-rail-1783245137938/01-artifact-button-collapsed.png` + - [x] 点击后 Pi 内部浮层:`/tmp/mnote-pi-artifact-rail-1783245137938/02-artifact-popover.png` + - [x] session JSONL 入口:`/tmp/mnote-pi-artifact-rail-1783245137938/03-session-jsonl-action.png` +- [x] 临时 Pi UI completion smoke 已纳入正式回归基线:`scripts/task-pi-lab-ui-completion-smoke.js`。 +- [x] History / session tree 已补齐 `打开 / 重命名 / Fork` 操作入口;重命名走 `PATCH /api/page-ai/pi/sessions/{session_id}`,Fork 以当前历史上下文启动新 Pi runtime。 +- [x] 高风险 MNote tool policy 已接入 Pi bridge 与后端强制校验:`policy=ask` 时未审批返回 `page_ai_pi_lab_tool_approval_required`,审批结果写入 tool receipt/audit UI。 +- [x] abort response 已返回并显示 `stopReason=aborted`、`command=abort` 与当前 queued steer/follow-up 数量。 +- [x] 新增 completion 浏览器 smoke 断言: + - [x] `hasHistoryOpen=true` + - [x] `hasHistoryRename=true` + - [x] `hasHistoryFork=true` + - [x] `unapprovedToolCode=page_ai_pi_lab_tool_approval_required` + - [x] `hasApprovalReceipt=true` + - [x] abort note 显示 `stopReason=aborted` 与 `queued steer=1 follow-up=1` +- [x] 使用 `npm run desktop:hot` 真启动环境完成 completion 浏览器验证,证据:`/tmp/mnote-pi-ui-completion-1783246949920/result.json`。 +- [x] Completion 截图已产出: + - [x] History 操作入口:`/tmp/mnote-pi-ui-completion-1783246949920/01-history-session-actions.png` + - [x] 高风险审批 receipt/audit:`/tmp/mnote-pi-ui-completion-1783246949920/02-approval-audit-ui.png` + - [x] Abort stopReason / queued messages:`/tmp/mnote-pi-ui-completion-1783246949920/03-abort-stopreason-queue.png` +- [x] 新增真实 Pi Skill/MCP 浏览器 smoke:`scripts/task-pi-lab-real-skill-mcp-smoke.js`,先为 `mnote-e2e` seed 全部 Skill/MCP 权限,再走真实 `/page-ai/pi` RPC 链路。 +- [x] 使用 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1 npm run desktop:hot` 真启动环境完成 Context7 Skill + Context7 MCP + MemPalace MCP 验证,证据:`/tmp/mnote-pi-real-skill-mcp-1783253985608/result.json`。 +- [x] 真实 Skill/MCP 截图已产出: + - [x] Pi RPC 启动:`/tmp/mnote-pi-real-skill-mcp-1783253985608/01-real-pi-started.png` + - [x] Context7 Skill/MCP:`/tmp/mnote-pi-real-skill-mcp-1783253985608/02-context7-skill-mcp.png` + - [x] MemPalace MCP:`/tmp/mnote-pi-real-skill-mcp-1783253985608/03-mempalace-mcp.png` +- [x] 真实 Skill/MCP smoke 断言通过: + - [x] `allSkillsEnabled=true` + - [x] `allMcpEnabled=true` + - [x] `context7SkillCliSource=true` + - [x] `vpnSkillCliSource=true` + - [x] `context7McpConfigured=true` + - [x] `mempalaceMcpConfigured=true` + - [x] `context7McpCalled=true` + - [x] `mempalaceMcpCalled=true` + - [x] `noReasoningLeakInToolTimeline=true` +- [x] 运行 `node --check rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js` 通过。 +- [x] 运行 `node --check scripts/task-pi-lab-ui-completion-smoke.js` 通过。 +- [x] 运行 `node --check scripts/task-pi-lab-real-skill-mcp-smoke.js` 通过。 +- [x] 运行 `cargo test -p mnote-web page_ai_pi --lib` 通过,11 tests passed。 +- [x] 运行 `cargo test -p mnote-web dev_seed --lib` 通过,3 tests passed。 +- [x] 运行 `codegraph sync . && codegraph status .`,索引 up to date。 +- [x] 对照 Pi 官方能力完成输入区收口:Pi 官方支持 `--thinking`,但无原生 permission popup / plan mode;MNote 侧展示 MNote 实际 tool policy / directory grant。 +- [x] 后端 `PiLabStartRequest` / `PiLabSession` 增加 `thinkingLevel`,真实 Pi RPC 启动时传 `--thinking `,并写入 runtime JSON。 +- [x] Pi 输入区新增 `+` 菜单,包含当前页、选区、LightRAG、目录权限、执行中引导/排队;附件/拍照与规划模式保持禁用,避免展示未接入假功能。 +- [x] Pi 输入区新增思考深度选择器与 MNote 权限/审批 chip,权限 chip 文案来自当前 runtime policy 与 allowed roots。 +- [x] completion 浏览器 smoke 增加 `+` 菜单、thinking control、permission chip、plan mode disabled 断言与截图。 +- [x] 使用当前 dev:hot 真启动环境完成输入区 `+` 菜单 / 权限 / thinking 浏览器验证,证据:`/mnt/Data1T/mnote/tmp/pi-plus-20260706-140632/result.json`。 +- [x] 输入区截图已产出:`/mnt/Data1T/mnote/tmp/pi-plus-20260706-140632/00a-plus-permission-thinking-menu.png`。 +- [x] 运行 `node --check rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js` 通过。 +- [x] 运行 `node --check scripts/task-pi-lab-ui-completion-smoke.js` 通过。 +- [x] 运行 `node scripts/task-pi-lab-static-smoke.js` 通过。 +- [x] 运行 `cargo test -p mnote-web page_ai_pi --lib` 通过,11 tests passed。 +- [x] 新增输入区真实可用性浏览器 smoke:`scripts/task-pi-lab-input-controls-smoke.js`,覆盖底部快捷按钮、`+` 菜单、权限 chip、thinking selector、发送按钮、streaming 中 steer/follow-up、目录权限入口、禁用附件/拍照/规划模式。 +- [x] 修复 Pi RPC `message_update.text_start` 后 UI 未进入 streaming 状态的问题,确保菜单里的“作为引导发送 / 作为排队追问发送”真实发送 `streamingBehavior=steer/followUp`。 +- [x] 修正 Pi 输入区目录权限入口为当前设置页真实 panel:`/user/ai#ai-admin-access`。 +- [x] 使用 `dev:hot` 真启动环境在 `http://127.0.0.1:3000` 完成输入区全部功能浏览器验证,真实 Pi `runtimeMode=rpc`,证据:`/mnt/Data1T/mnote/tmp/pi-input-3000-real-20260706-151725/result.json`。 +- [x] 输入区真实功能截图已产出: + - [x] 当前页/选区/LightRAG/权限 chip:`/mnt/Data1T/mnote/tmp/pi-input-3000-real-20260706-151725/01-context-actions-working.png` + - [x] streaming 中 steer/follow-up:`/mnt/Data1T/mnote/tmp/pi-input-3000-real-20260706-151725/02-streaming-send-menu-working.png` + - [x] 目录权限入口:`/mnt/Data1T/mnote/tmp/pi-input-3000-real-20260706-151725/03-directory-permission-entry.png` +- [x] 输入区 smoke 断言通过: + - [x] `bottomReadPage=true` + - [x] `bottomSelection=true` + - [x] `bottomRag=true` + - [x] `menuReadPage=true` + - [x] `menuSelection=true` + - [x] `menuRag=true` + - [x] `menuSteerStreamingBehavior=steer` + - [x] `menuFollowupStreamingBehavior=followUp` + - [x] `restartThinkingLevel=xhigh` + - [x] `sendButtonMessage="send button input controls smoke"` + - [x] `directoryPermissionUrl=http://127.0.0.1:3000/user/ai#ai-admin-access` + - [x] `attachDisabled=true` + - [x] `cameraDisabled=true` + - [x] `planDisabled=true` + +### 进行中 + +- 无。 + +### 未开始 + +- 无。 + +### Phase 1:运行事件时间线 + +目标:用户必须看见工具正在做什么。 + +- 后端把 Pi `tool_execution_start/update/end`、`message_update`、`queue_update`、`agent_start/end` 解包成稳定 SSE event。 +- 浏览器建立 `PiRunViewModel`: + - `turns[]` + - `messages[]` + - `toolCallsById` + - `pendingQueue` + - `currentAssistant` + - `runtimeStatus` +- 每个工具卡默认折叠显示:工具名、状态、摘要、耗时、allowed/denied。 +- 展开后显示:Input、Output、Raw details、Error。 +- `mnote.tool_receipt.write` 与 MCP 调用都映射成统一 tool card。 + +验收: + +- Context7 / MemPalace 测试截图中必须可见折叠工具卡。 +- 工具卡展开后能看到 server/tool/args/result 摘要。 +- browser audit 中 `hasToolTimeline=true`。 + +### Phase 2:Streaming 中断与追加回复 + +目标:补齐 Pi 官方 queue 语义。 + +- composer streaming 状态下允许继续输入。 +- 发送按钮旁提供 mode segmented control: + - `Steer`:发送 `{type:"prompt", streamingBehavior:"steer"}` + - `Follow-up`:发送 `{type:"prompt", streamingBehavior:"followUp"}` +- 后端暴露并持久化 `queue_update`。 +- Stop 调用 RPC abort 后显示 `abort` response 与 `stopReason=aborted`。 + +验收: + +- streaming 时输入一条 steer,UI 显示 queued/steering。 +- follow-up 在当前 turn 完成后自动执行。 +- Escape 或 Stop 后 assistant message 显示 aborted 状态,不把“已中止”混进正常 Markdown 正文。 + +### Phase 3:Extension UI Dialog 与通知 + +目标:让 Pi extension 的交互能力可见可控。 + +- 扩展桥支持 `ctx.ui.select/confirm/input/notify` 事件。 +- 浏览器提供统一 dialog host: + - confirm:标题、说明、Cancel/Confirm + - select:搜索/键盘选择 + - input:单行/多行输入 + - timeout / AbortSignal 自动关闭 +- 通知用右上角 toast stack,类型包括 info/warning/error/success。 +- 高风险工具审批复用 confirm,但审批结果必须写 audit/receipt。 + +验收: + +- 测试 extension 调 `ctx.ui.confirm()`,浏览器出现 modal。 +- AbortSignal 触发后 modal 关闭。 +- `ctx.ui.notify()` 可见且自动消退。 + +### Phase 4:Artifact / Diff / Citation Rail + +目标:把 Page AI 和 MNote 工作区真正接上。 + +- file patch 工具显示 diff summary 与 changed files。 +- LightRAG citation 显示引用卡,点击走 `mnote.reference.open`。 +- MCP 结果可作为 artifact 固定在右侧 rail。 +- 支持 copy raw / copy markdown / open session jsonl。 + +验收: + +- local file patch 后可见文件路径、diff summary、watcher refresh 状态。 +- LightRAG 回答中 citations 不只在文本中出现,而是可点击卡片。 + +### Phase 5:History / Session Tree / Replay + +目标:补齐 Pi 的 session 能力。 + +- 历史列表显示 session name、model、last turn、tool count、updated time。 +- 支持 resume、new、fork、rename。 +- 事件 replay 用同一 `PiRunViewModel`,历史会话不走另一套渲染。 +- debug 才显示原始 JSONL。 + +验收: + +- 重新打开 `/page-ai/pi` 可恢复最近 session。 +- 历史会话 tool cards、Markdown、receipts 展示一致。 + +## 7. 实施边界 + +- 不开放 Pi raw `bash/read/write/edit` 给普通 Page AI。 +- MCP 继续走 MNote facade-only / sandbox 控制面。 +- Tool card 展示不是权限来源;权限来源仍是 `AiAccessScope`、allowed roots、admin/user AI policy。 +- Debug / diagnostics 默认折叠,不挤占用户首屏。 +- 不把 OpenClaw 的 UI 代码直接复制进 MNote;只采用信息架构和交互模型。 + +## 8. 测试矩阵 + +- Unit: + - `PiRunViewModel` event reducer + - tool card folding + - queue_update reducer + - dialog lifecycle +- Browser smoke: + - `/page-ai/pi` start -> prompt -> streaming -> tool timeline -> final Markdown + - Context7 MCP call tool card + - MemPalace MCP call tool card + - Stop/abort visible state + - Steer/follow-up queue visible state + - extension confirm/select/input/notify +- Regression: + - `/api/ai-admin/settings` 删除 Skill/MCP 后 effective 不再出现残留 + - admin/user AI policy 不能越权启用全局未授权 skill/mcp/model/root diff --git a/packages/pi-mnote/extensions/mnote-bridge.ts b/packages/pi-mnote/extensions/mnote-bridge.ts new file mode 100644 index 00000000..875b8c2b --- /dev/null +++ b/packages/pi-mnote/extensions/mnote-bridge.ts @@ -0,0 +1,514 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +type ExtensionContext = { + hasUI?: boolean; + ui?: { + confirm?: (title: string, message: string, options?: Record) => Promise; + }; +}; + +type ExtensionAPI = { + registerTool: (spec: Record) => void; + on?: ( + eventName: string, + handler: (event: Record) => Record | void, + ) => void; +}; + +type MnoteToolManifestEntry = { + piName: string; + mnoteName: string; + label: string; + description: string; +}; + +const DEFAULT_TOOLS: MnoteToolManifestEntry[] = [ + { + piName: "mnote_current_page_read", + mnoteName: "mnote.current_page.read", + label: "MNote current page read", + description: "Read the current MNote page through MNote access scope.", + }, + { + piName: "mnote_selection_read", + mnoteName: "mnote.selection.read", + label: "MNote selection read", + description: "Read the current MNote editor selection snapshot supplied by MNote.", + }, + { + piName: "mnote_allowed_roots_describe", + mnoteName: "mnote.allowed_roots.describe", + label: "MNote allowed roots describe", + description: "Describe MNote allowed roots and disabled raw tools.", + }, + { + piName: "mnote_local_file_read", + mnoteName: "mnote.local_file.read", + label: "MNote local file read", + description: "Read a file only through MNote allowed roots.", + }, + { + piName: "mnote_local_file_patch", + mnoteName: "mnote.local_file.patch", + label: "MNote local file patch", + description: "Patch a file only through MNote allowed roots and watcher refresh.", + }, + { + piName: "mnote_knowledge_rag_status", + mnoteName: "mnote.knowledge_rag.status", + label: "MNote LightRAG status", + description: "Check the MNote LightRAG knowledge provider status, indexed source registry, dashboard URL, and sync state. Use before knowledge-base questions when availability is uncertain. Params: optional workspaceId/rootUri; MNote fills the current Pi session context when omitted.", + }, + { + piName: "mnote_knowledge_rag_query", + mnoteName: "mnote.knowledge_rag.query", + label: "MNote LightRAG query", + description: "Ask the MNote LightRAG knowledge library across indexed books, papers, Office files, PDFs, images, and attachments. Use this for knowledge-base questions and answers that require sources, citations, or evidence. For book or long-document questions, pass query, mode='naive' or 'mix', topK, chunkTopK, includeChunkContent=true, and includeDocumentStructureIndex=true. Use returned references/citations quotes as evidence; do not invent page numbers or hand-write /documents/mnote:// links.", + }, + { + piName: "mnote_knowledge_rag_section_context", + mnoteName: "mnote.knowledge_rag.section_context", + label: "MNote LightRAG section context", + description: "Read bounded section blocks/chunks from a LightRAG sidecar using documentStructureIndex ranges returned by mnote_knowledge_rag_query. Use this for second-pass reading of large books or long documents when query references are not enough. Params include sourcePath/sourceId/lightRagDocId/filePath/sectionId, block or paragraph ordinal range, contextBefore/contextAfter, maxBlocks, maxChars.", + }, + { + piName: "mnote_knowledge_rag_open_reference", + mnoteName: "mnote.knowledge_rag.open_reference", + label: "MNote LightRAG open reference", + description: "Convert a LightRAG reference, filePath, or chunkId returned by mnote_knowledge_rag_query into a MNote clickable local resource locator. Use when the user asks to open or verify a cited source.", + }, + { + piName: "mnote_reference_open", + mnoteName: "mnote.reference.open", + label: "MNote reference open", + description: "Legacy alias for opening a citation/reference through MNote mapping. Prefer mnote_knowledge_rag_open_reference for LightRAG references.", + }, + { + piName: "mnote_codex_rescue_request", + mnoteName: "mnote.codex_rescue.request", + label: "MNote Codex rescue", + description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.", + }, + { + piName: "mnote_tool_receipt_write", + mnoteName: "mnote.tool_receipt.write", + label: "MNote tool receipt write", + description: "Write a provider-neutral MNote tool receipt.", + }, +]; + +const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url)); +const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json"); +const CONTEXT_FILE = env("PI_MNOTE_CONTEXT_FILE") + || (fs.existsSync(DEFAULT_CONTEXT_FILE) ? DEFAULT_CONTEXT_FILE : ""); +const RUNTIME_IMPL = env("PI_MNOTE_RUNTIME_IMPL") + || env("MNOTE_PI_RUNTIME_IMPL") + || (CONTEXT_FILE ? "pi-rust" : ""); +const BASE_URL = env("PI_MNOTE_BRIDGE_BASE_URL") || env("MNOTE_PI_BRIDGE_BASE_URL") || env("MNOTE_PI_LAB_BASE_URL") || "http://127.0.0.1:3000"; +const SESSION_ID = env("PI_MNOTE_BRIDGE_SESSION_ID") || env("MNOTE_PI_BRIDGE_SESSION_ID") || env("MNOTE_PI_LAB_SESSION_ID") || ""; +const BRIDGE_TOKEN = env("PI_MNOTE_BRIDGE_TOKEN") || env("MNOTE_PI_BRIDGE_TOKEN") || env("MNOTE_PI_LAB_BRIDGE_TOKEN") || ""; +const HTTP_BRIDGE_AVAILABLE = env("PI_MNOTE_HTTP_BRIDGE_AVAILABLE") === "1"; +const TOOL_POLICIES = parseJsonRecord(env("PI_MNOTE_BRIDGE_TOOL_POLICIES") || env("MNOTE_PI_BRIDGE_TOOL_POLICIES"), {}); +const TOOLS = normalizeTools(parseJsonUnknown(env("PI_MNOTE_BRIDGE_TOOLS") || env("MNOTE_PI_BRIDGE_TOOLS")) ?? DEFAULT_TOOLS); +const CONTEXT_PREFIX = "[[MNOTE_PI_CONTEXT_V1:"; +let liveContext: Record | undefined; + +function env(name: string): string { + return (process.env[name] || "").trim(); +} + +function parseJsonUnknown(raw: string): unknown { + if (!raw) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function parseJsonRecord(raw: string, fallback: Record): Record { + const parsed = parseJsonUnknown(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : fallback; +} + +function normalizeTools(value: unknown): MnoteToolManifestEntry[] { + if (!Array.isArray(value)) return DEFAULT_TOOLS; + const tools = value + .map((entry) => { + if (!entry || typeof entry !== "object") return undefined; + const record = entry as Record; + const piName = stringField(record, "piName") || stringField(record, "pi_name"); + const mnoteName = stringField(record, "mnoteName") || stringField(record, "mnote_name"); + const label = stringField(record, "label") || piName; + const description = stringField(record, "description") || label; + if (!piName || !mnoteName) return undefined; + return { piName, mnoteName, label, description }; + }) + .filter((entry): entry is MnoteToolManifestEntry => Boolean(entry)); + return tools.length ? tools : DEFAULT_TOOLS; +} + +function stringField(record: Record, key: string): string { + const value = record[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function toolResult(payload: Record, isError = false) { + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + details: payload, + isError, + }; +} + +function decodeHexJson(value: string): Record { + if (!value || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) { + throw new Error("MNote Pi context hex 无效"); + } + let jsonText = ""; + for (let index = 0; index < value.length; index += 2) { + jsonText += String.fromCharCode(Number.parseInt(value.slice(index, index + 2), 16)); + } + const parsed = JSON.parse(decodeURIComponent(escape(jsonText))); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("MNote Pi context payload 格式无效"); + } + return parsed as Record; +} + +function captureInputContext(event: Record) { + const text = typeof event.text === "string" ? event.text : ""; + const start = text.indexOf(CONTEXT_PREFIX); + if (start < 0) return { action: "continue" }; + const end = text.indexOf("]]\n", start); + if (end < 0) return { action: "continue" }; + const encoded = text.slice(start + CONTEXT_PREFIX.length, end); + try { + liveContext = decodeHexJson(encoded); + } catch (error) { + liveContext = { + contextError: error instanceof Error ? error.message : String(error), + }; + } + return { + action: "transform", + text: `${text.slice(0, start)}${text.slice(end + 3)}`, + images: event.images, + }; +} + +function readContextSnapshot(): Record { + if (liveContext) return liveContext; + if (!CONTEXT_FILE) { + throw new Error("PI_MNOTE_CONTEXT_FILE 未配置"); + } + const raw = fs.readFileSync(CONTEXT_FILE, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("MNote Pi 上下文文件格式无效"); + } + return parsed as Record; +} + +function contextAllowedRoots(context: Record): Record[] { + const snapshot = context.allowedRoots; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return []; + const roots = (snapshot as Record).roots; + if (!Array.isArray(roots)) return []; + return roots.filter((root): root is Record => ( + Boolean(root) && typeof root === "object" && !Array.isArray(root) + )); +} + +function pathIsInside(target: string, root: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function resolveAllowedTarget( + context: Record, + rootUri: string, + relativePath: string, +): { target: string; readPath: string } { + const roots = contextAllowedRoots(context); + const matchedRoot = roots.find((root) => stringField(root, "rootUri") === rootUri); + const rootPath = matchedRoot + ? stringField(matchedRoot, "rootPath") + : stringField(context, "primaryRootPath") || process.cwd(); + if (!rootPath) throw new Error("MNote allowed root 缺少 rootPath"); + + const canonicalRoot = fs.realpathSync(rootPath); + const requestedTarget = path.isAbsolute(relativePath) + ? path.resolve(relativePath) + : path.resolve(canonicalRoot, relativePath); + const canonicalTarget = fs.realpathSync(requestedTarget); + const allowedRoots = roots + .map((root) => stringField(root, "rootPath")) + .filter(Boolean) + .map((root) => fs.realpathSync(root)); + const candidates = allowedRoots.length ? allowedRoots : [canonicalRoot]; + if (!candidates.some((root) => pathIsInside(canonicalTarget, root))) { + throw new Error(`路径超出 MNote allowed roots: ${relativePath}`); + } + const processRoot = fs.realpathSync(process.cwd()); + const readPath = pathIsInside(canonicalTarget, processRoot) + ? path.relative(processRoot, canonicalTarget) || "." + : canonicalTarget; + return { target: canonicalTarget, readPath }; +} + +function executeNativeCurrentPageRead(params: unknown) { + try { + const input = params && typeof params === "object" && !Array.isArray(params) + ? params as Record + : {}; + const context = readContextSnapshot(); + const rootUri = stringField(context, "rootUri") + || stringField(input, "rootUri") + || stringField(input, "root_uri"); + const pagePath = stringField(context, "pagePath") + || stringField(input, "pagePath") + || stringField(input, "page_path") + || stringField(input, "path"); + if (!rootUri) throw new Error("读取当前页缺少 rootUri"); + if (!pagePath) throw new Error("读取当前页缺少 pagePath"); + + const resolved = resolveAllowedTarget(context, rootUri, pagePath); + const content = fs.readFileSync(resolved.readPath, "utf8"); + const stat = fs.statSync(resolved.readPath); + return toolResult({ + ok: true, + rootUri, + pagePath, + path: resolved.target, + content, + contentLength: content.length, + format: "markdown", + fileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`, + transport: "pi-rust-native-fs", + contextFile: CONTEXT_FILE, + }); + } catch (error) { + return toolResult({ + ok: false, + code: "mnote_pi_rust_current_page_read_failed", + message: error instanceof Error ? error.message : String(error), + transport: "pi-rust-native-fs", + contextFile: CONTEXT_FILE, + }, true); + } +} + +function executeNativeSelectionRead() { + try { + const context = readContextSnapshot(); + return toolResult({ + ok: true, + selection: context.selectedContext ?? null, + selectionSource: "mnote_sidebar_host_snapshot", + rootUri: context.rootUri ?? null, + pagePath: context.pagePath ?? null, + contextRefs: context.contextRefs ?? [], + transport: "pi-rust-native-context-file", + }); + } catch (error) { + return toolResult({ + ok: false, + code: "mnote_pi_rust_selection_read_failed", + message: error instanceof Error ? error.message : String(error), + transport: "pi-rust-native-context-file", + }, true); + } +} + +function executeNativeAllowedRootsDescribe() { + try { + const context = readContextSnapshot(); + return toolResult({ + ok: true, + allowedRoots: context.allowedRoots ?? { roots: [] }, + rootUri: context.rootUri ?? null, + pagePath: context.pagePath ?? null, + primaryRootPath: context.primaryRootPath ?? process.cwd(), + transport: "pi-rust-native-context-file", + }); + } catch (error) { + return toolResult({ + ok: false, + code: "mnote_pi_rust_allowed_roots_read_failed", + message: error instanceof Error ? error.message : String(error), + transport: "pi-rust-native-context-file", + }, true); + } +} + +function executeNativeTool(tool: MnoteToolManifestEntry, params: unknown) { + if (runtimeImplementation() !== "pi-rust" || toolPolicy(tool.mnoteName) !== "allow") return undefined; + if (tool.mnoteName === "mnote.current_page.read") return executeNativeCurrentPageRead(params); + if (tool.mnoteName === "mnote.selection.read") return executeNativeSelectionRead(); + if (tool.mnoteName === "mnote.allowed_roots.describe") return executeNativeAllowedRootsDescribe(); + return undefined; +} + +async function callMnote(toolName: string, params: unknown) { + const response = await fetch(`${BASE_URL}/api/page-ai/pi/tool-call-bridge`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN, + }, + body: JSON.stringify({ sessionId: SESSION_ID, toolName, params: params || {} }), + }); + const payload = await response.json().catch(() => ({ ok: false, code: "bad_json" })); + const text = JSON.stringify((payload as Record).result || payload, null, 2); + return { + content: [{ type: "text", text }], + details: payload, + }; +} + +function stableJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; +} + +function stableHash(value: string): string { + let hash = 5381 >>> 0; + for (let index = 0; index < value.length; index += 1) { + hash = (((hash << 5) + hash) + value.charCodeAt(index)) >>> 0; + } + return hash.toString(16); +} + +function paramsHash(params: unknown): string { + const copy = { ...((params || {}) as Record) }; + delete copy.mnoteApproval; + delete copy.mnote_approval; + return stableHash(stableJson(copy)); +} + +function toolPolicy(toolName: string): string { + const contextPolicies = liveContext?.toolPolicies; + const contextValue = contextPolicies && typeof contextPolicies === "object" && !Array.isArray(contextPolicies) + ? (contextPolicies as Record)[toolName] + : undefined; + const value = contextValue ?? TOOL_POLICIES[toolName]; + return typeof value === "string" && value.trim() ? value.trim() : "allow"; +} + +function runtimeImplementation(): string { + return liveContext ? stringField(liveContext, "runtimeImplementation") || RUNTIME_IMPL : RUNTIME_IMPL; +} + +async function requestMnoteApprovalViaBridge(request: Record): Promise> { + const response = await fetch(`${BASE_URL}/api/page-ai/pi/ui-request-bridge`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN, + }, + body: JSON.stringify({ sessionId: SESSION_ID, ...request }), + }); + return await response.json().catch(() => ({ ok: false, cancelled: true, code: "bad_json" })); +} + +async function requestMnoteApproval( + ctx: ExtensionContext | undefined, + toolCallId: string, + toolName: string, + label: string, + params: unknown, +): Promise> { + if (toolPolicy(toolName) !== "ask") return null; + const approvalId = toolCallId || `approval_${Date.now()}_${Math.random().toString(16).slice(2)}`; + const approval = { approvalId, toolName, paramsHash: paramsHash(params) }; + const summary = JSON.stringify(params || {}, null, 2).slice(0, 1200); + const message = `${label}\n${toolName}\n\n${summary}`; + + if (ctx?.hasUI && ctx.ui && typeof ctx.ui.confirm === "function") { + const result = await ctx.ui.confirm("审批 Pi 工具调用", message, { timeout: 60000 }); + if (result === true) return approval; + return { ...approval, denied: true }; + } + + const result = await requestMnoteApprovalViaBridge({ + id: approvalId, + method: "confirm", + title: "审批 Pi 工具调用", + message, + timeoutMs: 60000, + mnoteApproval: approval, + }); + if (result && result.confirmed === true) return approval; + return { ...approval, denied: true }; +} + +function register(pi: ExtensionAPI, tool: MnoteToolManifestEntry) { + pi.registerTool({ + name: tool.piName, + label: tool.label, + description: tool.description, + promptSnippet: `${tool.label}: ${tool.description}`, + parameters: { type: "object", additionalProperties: true }, + execute(toolCallId: unknown, params: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext | undefined) { + if (toolPolicy(tool.mnoteName) === "deny") { + return toolResult({ + ok: false, + code: "mnote_tool_denied_by_permission_mode", + message: `当前 Pi 模式禁止调用工具 ${tool.mnoteName}`, + toolName: tool.mnoteName, + }, true); + } + const nativeResult = executeNativeTool(tool, params); + if (nativeResult) return nativeResult; + if (runtimeImplementation() === "pi-rust" && !HTTP_BRIDGE_AVAILABLE) { + return toolResult({ + ok: false, + code: "mnote_pi_rust_service_bridge_unavailable", + message: `${tool.mnoteName} 仍依赖旧 HTTP bridge;Pi Rust 当前应通过原生文件工具或专用 MCP 扩展调用。`, + toolName: tool.mnoteName, + runtimeImplementation: runtimeImplementation(), + }, true); + } + return executeBridgeTool(toolCallId, params, ctx, tool); + }, + }); +} + +async function executeBridgeTool( + toolCallId: unknown, + params: unknown, + ctx: ExtensionContext | undefined, + tool: MnoteToolManifestEntry, +) { + const nextParams = { ...((params || {}) as Record) }; + const approval = await requestMnoteApproval(ctx, String(toolCallId || ""), tool.mnoteName, tool.label, nextParams); + if (approval && approval.denied) { + return { + content: [{ type: "text", text: JSON.stringify({ ok: false, code: "mnote_tool_approval_cancelled", message: "用户拒绝或未完成 MNote 工具审批", toolName: tool.mnoteName }, null, 2) }], + details: { ok: false, code: "mnote_tool_approval_cancelled", toolName: tool.mnoteName }, + }; + } + if (toolPolicy(tool.mnoteName) === "ask") { + nextParams.mnoteApproval = { + ...approval, + confirmed: true, + method: "extension_ui_confirm", + toolCallId: String(toolCallId || ""), + toolName: tool.mnoteName, + approvedAt: new Date().toISOString(), + }; + } + return callMnote(tool.mnoteName, nextParams); +} + +export default function mnotePi(pi: ExtensionAPI) { + pi.on?.("input", captureInputContext); + for (const tool of TOOLS) register(pi, tool); +} diff --git a/packages/pi-mnote/extensions/mnote-mcp/.pi/mcp.json b/packages/pi-mnote/extensions/mnote-mcp/.pi/mcp.json new file mode 100644 index 00000000..3484a5d8 --- /dev/null +++ b/packages/pi-mnote/extensions/mnote-mcp/.pi/mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "example-filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "disabled": true + } + } +} diff --git a/packages/pi-mnote/extensions/mnote-mcp/client.mjs b/packages/pi-mnote/extensions/mnote-mcp/client.mjs new file mode 100644 index 00000000..79366843 --- /dev/null +++ b/packages/pi-mnote/extensions/mnote-mcp/client.mjs @@ -0,0 +1,521 @@ +/** + * MNote MCP Client — 最小 MCP client,仅使用 Node 内置模块 + * + * 支持协议: + * - stdio JSONL (child_process.spawn) + * - streamable-http POST (http/https 模块) + * + * 请求输入格式 (JSON): + * { "server": "name", "mode": "list|status|call", + * "tool": "toolName", "arguments": {} } + * 默认读取 argv[2] 指向的文件;argv[2] 为 "-" 时从 stdin 读取; + * argv[2] 为 "--request-json" 时直接读取 argv[3]。 + * + * 输出:stdout 打印 JSON,stderr 仅用于诊断 + * + * 限制:不含 OAuth/UI/资源写入 + */ + +import { spawn } from "node:child_process"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +/* ============================================================ + * 常量 + * ============================================================ */ +const MCP_VERSION = "2024-11-05"; +const CLIENT_NAME = "mnote-mcp-client"; +const CLIENT_VERSION = "0.1.0"; +const REQUEST_TIMEOUT = 30_000; +const SSE_TIMEOUT = 60_000; + +/* ============================================================ + * 工具 + * ============================================================ */ +function resolveEnv(value) { + if (typeof value !== "string") return value; + return value.replace(/\$\{(\w+)\}/g, (_, key) => process.env[key] ?? ""); +} + +let _reqId = 0; +function nextId() { return ++_reqId; } + +function errorResult(message, details = null) { + return { ok: false, error: message, details, timestamp: new Date().toISOString() }; +} +function okResult(data) { + return { ok: true, data, timestamp: new Date().toISOString() }; +} +function extractResult(resp) { + if (resp == null) throw new Error("Empty response from server"); + if (resp.error) throw new Error(`JSON-RPC error: ${resp.error.message || JSON.stringify(resp.error)}`); + if (resp.result !== undefined) return resp.result; + return resp; +} + +/* ============================================================ + * 读取 MCP 配置 + * ============================================================ */ +function loadMcpConfig(extDir) { + const configPath = process.env.MNOTE_MCP_CONFIG_PATH || resolve(extDir, ".pi", "mcp.json"); + if (!existsSync(configPath)) return { servers: {} }; + try { + const raw = readFileSync(configPath, "utf-8").trim(); + if (!raw) return { servers: {} }; + const parsed = JSON.parse(raw); + const servers = parsed.mcpServers || parsed.servers || parsed; + if (typeof servers !== "object" || Array.isArray(servers)) { + return { servers: {}, _parseError: "Config root must be an object with mcpServers key" }; + } + return { servers }; + } catch (e) { + return { servers: {}, _parseError: e.message }; + } +} + +/* ============================================================ + * stdio transport + * 行读取器 + 响应队列,支持 send(等响应)和 sendNotify(不等) + * ============================================================ */ +function createStdioTransport(serverConfig, timeout = REQUEST_TIMEOUT) { + const cmd = resolveEnv(serverConfig.command); + if (!cmd) throw new Error("stdio transport requires command"); + const args = (serverConfig.args || []).map(resolveEnv); + const env = serverConfig.env + ? { ...process.env, ...Object.fromEntries( + Object.entries(serverConfig.env).map(([k, v]) => [k, resolveEnv(v)]) + )} + : process.env; + + return new Promise((resolvePromise, rejectPromise) => { + const detached = process.platform !== "win32"; + const child = spawn(cmd, args, { + env, + stdio: ["pipe", "pipe", "pipe"], + shell: false, + detached, + }); + + let buf = ""; + let stderrBuf = ""; + let pending = null; // { resolve, reject, timer } + let closed = false; + + const startTimer = () => { + return setTimeout(() => { + if (pending) { + const p = pending; + pending = null; + p.reject(new Error(`Response timeout after ${timeout}ms`)); + clearTimeout(p.timer); + } + }, timeout); + }; + + child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + if (!pending) return; + const nl = buf.indexOf("\n"); + if (nl < 0) return; + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + const p = pending; + pending = null; + clearTimeout(p.timer); + try { p.resolve(JSON.parse(line)); } + catch { p.reject(new Error(`Invalid JSON: ${line.slice(0, 200)}`)); } + }); + + child.stderr.on("data", (chunk) => { stderrBuf += chunk.toString(); }); + + child.on("error", (err) => { + if (closed) return; + closed = true; + if (pending) { pending.reject(new Error(`Spawn error: ${err.message}`)); clearTimeout(pending.timer); pending = null; } + rejectPromise(new Error(`Spawn error: ${err.message}`)); + }); + + child.on("close", (code) => { + if (closed) return; + closed = true; + if (pending) { + pending.reject(new Error(`Process exited (${code}) before response: ${stderrBuf.slice(0, 300)}`)); + clearTimeout(pending.timer); + pending = null; + } + }); + + const transport = { + _transport: "stdio", + _child: child, + + /** 发送并等待响应 */ + async send(msg) { + if (closed) throw new Error("Transport closed"); + if (pending) throw new Error("Concurrent stdio MCP requests are not supported"); + const line = JSON.stringify(msg) + "\n"; + return new Promise((resolve, reject) => { + const timer = startTimer(); + const current = { resolve, reject, timer }; + pending = current; + child.stdin.write(line, (err) => { + if (!err || pending !== current) return; + pending = null; + clearTimeout(timer); + reject(new Error(`Write error: ${err.message}`)); + }); + // 尝试立即读取(数据可能已在缓冲区) + if (!pending) return; + const nl = buf.indexOf("\n"); + if (nl < 0) return; + const p = pending; + pending = null; + clearTimeout(p.timer); + const line2 = buf.slice(0, nl); + buf = buf.slice(nl + 1); + try { p.resolve(JSON.parse(line2)); } + catch { p.reject(new Error(`Invalid JSON: ${line2.slice(0, 200)}`)); } + }); + }, + + /** 发送通知(不等待响应) */ + async sendNotify(msg) { + if (closed) return; + const line = JSON.stringify(msg) + "\n"; + return new Promise((resolve, reject) => { + child.stdin.write(line, (err) => { + if (err) reject(new Error(`Write error: ${err.message}`)); + else resolve(); + }); + }); + }, + + async close() { + if (pending) { pending.reject(new Error("Transport closed")); clearTimeout(pending.timer); pending = null; } + if (closed) return; + closed = true; + child.stdin.end(); + const kill = (signal) => { + try { + if (detached && child.pid) process.kill(-child.pid, signal); + else child.kill(signal); + } catch {} + }; + kill("SIGTERM"); + await new Promise((resolve) => { + if (child.exitCode !== null) { + resolve(); + return; + } + const forceTimer = setTimeout(() => { + kill("SIGKILL"); + resolve(); + }, 1000); + child.once("close", () => { + clearTimeout(forceTimer); + resolve(); + }); + }); + }, + }; + + resolvePromise(transport); + }); +} + +/* ============================================================ + * HTTP transport + * ============================================================ */ +function createHttpTransport(serverConfig) { + const urlStr = resolveEnv(serverConfig.url); + if (!urlStr) throw new Error("HTTP transport requires url"); + const url = new URL(urlStr); + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error(`Unsupported HTTP URL scheme: ${url.protocol}`); + } + const isHttps = url.protocol === "https:"; + const requester = isHttps ? httpsRequest : httpRequest; + const configuredHeaders = Object.fromEntries( + Object.entries(serverConfig.headers || {}).map(([key, value]) => [key, resolveEnv(value)]), + ); + let sessionId = null; + + function parseSseResponse(raw, requestId) { + const payloads = []; + let dataLines = []; + const flush = () => { + if (dataLines.length === 0) return; + const data = dataLines.join("\n"); + dataLines = []; + try { + payloads.push(JSON.parse(data)); + } catch { + payloads.push({ _rawData: data }); + } + }; + for (const rawLine of raw.split(/\r?\n/)) { + if (rawLine === "") { + flush(); + continue; + } + if (rawLine.startsWith("data:")) { + dataLines.push(rawLine.slice(5).trimStart()); + } + } + flush(); + return payloads.find((payload) => payload?.id === requestId) + || payloads.find((payload) => payload?.result !== undefined || payload?.error) + || payloads.at(-1) + || { _sseRaw: raw }; + } + + return { + _transport: "http", + _url: urlStr, + + async send(msg, t = SSE_TIMEOUT) { + const body = JSON.stringify(msg); + return new Promise((resolve, reject) => { + let settled = false; + let req; + const finishResolve = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const finishReject = (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }; + const timer = setTimeout(() => { + req?.destroy(); + finishReject(new Error(`HTTP timeout after ${t}ms`)); + }, t); + const headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": MCP_VERSION, + ...configuredHeaders, + }; + if (sessionId) headers["Mcp-Session-Id"] = sessionId; + const opts = { + hostname: url.hostname, port: url.port, path: url.pathname + url.search, + method: "POST", + headers, + }; + req = requester(opts, (res) => { + sessionId = res.headers["mcp-session-id"] || sessionId; + const ct = res.headers["content-type"] || ""; + const chunks = []; + res.on("data", (c) => chunks.push(c)); + res.on("end", () => { + const raw = Buffer.concat(chunks).toString(); + if ((res.statusCode || 500) >= 400) { + finishReject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 500)}`)); + return; + } + if (!raw.trim()) { + finishResolve({}); + return; + } + if (ct.includes("text/event-stream")) { + finishResolve(parseSseResponse(raw, msg.id)); + } else { + try { finishResolve(JSON.parse(raw)); } + catch { finishResolve({ _raw: raw }); } + } + }); + res.on("error", (err) => finishReject(new Error(`HTTP response error: ${err.message}`))); + }); + req.on("error", (err) => finishReject(new Error(`HTTP error: ${err.message}`))); + req.write(body); + req.end(); + }); + }, + + async sendNotify(msg) { + await this.send(msg, 5000); + }, + + async close() {}, + }; +} + +async function createTransport(serverConfig) { + const transport = String(serverConfig.transport || "").trim().toLowerCase(); + if (serverConfig.url || transport === "streamable-http" || transport === "sse" || transport === "http") { + return createHttpTransport(serverConfig); + } + if (serverConfig.command || transport === "stdio" || !transport) { + return createStdioTransport(serverConfig); + } + throw new Error(`Unsupported MCP transport: ${transport}`); +} + +/* ============================================================ + * MCP 会话 + * ============================================================ */ +class McpSession { + constructor(transport, serverName) { + this.transport = transport; + this.serverName = serverName; + this.initialized = false; + this.serverCapabilities = null; + this.serverVersion = null; + } + + async initialize() { + const initMsg = { + jsonrpc: "2.0", id: nextId(), method: "initialize", + params: { + protocolVersion: MCP_VERSION, + capabilities: { tools: {} }, + clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION }, + }, + }; + const resp = await this.transport.send(initMsg, REQUEST_TIMEOUT); + const result = extractResult(resp); + + if (result.protocolVersion) { + this.serverCapabilities = result.capabilities || {}; + this.serverVersion = result.serverInfo?.name + ? `${result.serverInfo.name} ${result.serverInfo.version || ""}` + : "unknown"; + this.initialized = true; + + // 通知(不等待响应) + const notif = { jsonrpc: "2.0", method: "notifications/initialized" }; + this.transport.sendNotify(notif).catch(() => {}); + } else { + throw new Error(`Unexpected initialize result: ${JSON.stringify(result).slice(0, 300)}`); + } + } + + async listTools() { + if (!this.initialized) await this.initialize(); + const msg = { jsonrpc: "2.0", id: nextId(), method: "tools/list", params: {} }; + const resp = await this.transport.send(msg, REQUEST_TIMEOUT); + const result = extractResult(resp); + if (result.tools) return result.tools; + throw new Error(`tools/list missing 'tools': ${JSON.stringify(result).slice(0, 300)}`); + } + + async callTool(name, args = {}) { + if (!this.initialized) await this.initialize(); + const msg = { + jsonrpc: "2.0", id: nextId(), method: "tools/call", + params: { name, arguments: args }, + }; + const resp = await this.transport.send(msg, REQUEST_TIMEOUT); + return extractResult(resp); + } + + async close() { await this.transport.close(); } +} + +async function readRequestInput(requestArg, inlineJson) { + if (requestArg === "--request-json") { + if (!inlineJson) throw new Error("--request-json 缺少 JSON 参数"); + return inlineJson; + } + if (requestArg !== "-") { + return readFileSync(requestArg, "utf-8"); + } + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf-8"); +} + +/* ============================================================ + * 主流程 + * ============================================================ */ +async function main() { + const requestArg = process.argv[2]; + if (!requestArg) { + console.log(JSON.stringify(errorResult( + "Usage: node client.mjs | --request-json ''", + ))); + process.exit(1); + } + + let request; + try { request = JSON.parse(await readRequestInput(requestArg, process.argv[3])); } + catch (e) { console.log(JSON.stringify(errorResult(`Cannot read request: ${e.message}`))); process.exit(1); } + + const { server: serverName, mode } = request; + if (!serverName) { console.log(JSON.stringify(errorResult("Missing 'server'"))); process.exit(1); } + + const extDir = dirname(fileURLToPath(import.meta.url)); + const config = loadMcpConfig(extDir); + const serverConfig = config.servers[serverName]; + + if (mode === "status") { + const result = { + configured: !!serverConfig, serverName, + configError: config._parseError || null, + config: serverConfig ? { + transport: serverConfig.transport || null, + command: serverConfig.command || null, url: serverConfig.url || null, + argsCount: serverConfig.args?.length || 0, hasEnv: !!serverConfig.env, disabled: !!serverConfig.disabled, + } : null, + availableServers: Object.keys(config.servers), + }; + if (serverConfig && !serverConfig.disabled) { + try { + const transport = await createTransport(serverConfig); + const session = new McpSession(transport, serverName); + await session.initialize(); + result.connected = true; + result.serverVersion = session.serverVersion; + result.serverCapabilities = session.serverCapabilities; + await session.close(); + } catch (e) { result.connected = false; result.connectError = e.message; } + } else if (serverConfig?.disabled) { result.statusNote = "disabled"; } + console.log(JSON.stringify(okResult(result))); + return; + } + + if (!serverConfig) { + console.log(JSON.stringify(errorResult(`Server "${serverName}" not found. Available: ${Object.keys(config.servers).join(", ") || "(none)"}`))); + process.exit(1); + } + if (serverConfig.disabled) { + console.log(JSON.stringify(errorResult(`Server "${serverName}" is disabled`))); + process.exit(1); + } + + let transport; + try { transport = await createTransport(serverConfig); } + catch (e) { console.log(JSON.stringify(errorResult(`Transport error: ${e.message}`))); process.exit(1); } + + const session = new McpSession(transport, serverName); + try { + if (mode === "list") { + const tools = await session.listTools(); + console.log(JSON.stringify(okResult({ server: serverName, tools, toolCount: tools.length }))); + } else if (mode === "call") { + const { tool, arguments: args } = request; + if (!tool) { console.log(JSON.stringify(errorResult("Missing 'tool'"))); process.exit(1); } + const result = await session.callTool(tool, args || {}); + console.log(JSON.stringify(okResult({ server: serverName, tool, result }))); + } else { + console.log(JSON.stringify(errorResult(`Unknown mode: ${mode}`))); + process.exit(1); + } + } catch (e) { + console.log(JSON.stringify(errorResult(`MCP ${mode} error: ${e.message}`))); + } finally { + await session.close(); + } +} + +main().catch((e) => { + console.log(JSON.stringify(errorResult(`Fatal: ${e.message}`))); + process.exit(1); +}); diff --git a/packages/pi-mnote/extensions/mnote-mcp/index.ts b/packages/pi-mnote/extensions/mnote-mcp/index.ts new file mode 100644 index 00000000..8853ba6c --- /dev/null +++ b/packages/pi-mnote/extensions/mnote-mcp/index.ts @@ -0,0 +1,154 @@ +/** + * MNote MCP 扩展 — Pi Rust MCP 工具门面 + * + * Pi Rust 0.1.21 的异步 pi.exec()/嵌套 HTTP hostcall 在真实网页工具调用中 + * 可能卡到扩展任务超时。官方 node:child_process shim 的 execFileSync 通过 + * __pi_exec_sync_native 执行,因此这里同步启动相邻 client.mjs。 + * + * client.mjs 只读取扩展目录相邻的 .pi/mcp.json,并且请求只能选择其中已配置 + * 的 server,不接受任意命令或配置路径。 + */ + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +interface ExtensionAPI { + registerTool: (spec: Record) => void; +} + +interface ToolResult { + content?: Array<{ type: string; text?: string; [key: string]: unknown }>; + details?: Record; + isError?: boolean; + [key: string]: unknown; +} + +const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url)); +const MCP_CLIENT_PATH = path.join(EXTENSION_DIR, "client.mjs"); +const MCP_REQUEST_MAX_CHARS = 128 * 1024; +const MCP_CLIENT_TIMEOUT_MS = 90_000; +const MCP_CLIENT_MAX_BUFFER = 2 * 1024 * 1024; + +const mcpToolParameters = { + type: "object", + properties: { + server: { + type: "string", + description: "MCP 服务器名称(对应当前 MNote Pi session 的 mcp.json)", + }, + mode: { + type: "string", + enum: ["list", "status", "call"], + description: "操作模式:list=列出工具,status=检查服务器状态,call=调用工具", + }, + tool: { + type: "string", + description: "mode=call 时需要调用的工具名称", + }, + arguments: { + type: "object", + description: "mode=call 时传入工具的参数对象", + additionalProperties: true, + }, + }, + required: ["server", "mode"], +}; + +function executeMcpRequest(params: { + server: string; + mode: string; + tool?: string; + arguments?: Record; +}): Record { + if (!existsSync(MCP_CLIENT_PATH)) { + throw new Error(`MNote MCP client 不存在: ${MCP_CLIENT_PATH}`); + } + const requestJson = JSON.stringify({ + server: params.server, + mode: params.mode, + tool: params.tool, + arguments: params.arguments || {}, + }); + if (requestJson.length > MCP_REQUEST_MAX_CHARS) { + throw new Error(`MCP 请求超过 ${MCP_REQUEST_MAX_CHARS} 字符限制`); + } + const stdout = execFileSync("node", [ + MCP_CLIENT_PATH, + "--request-json", + requestJson, + ], { + cwd: EXTENSION_DIR, + timeout: MCP_CLIENT_TIMEOUT_MS, + maxBuffer: MCP_CLIENT_MAX_BUFFER, + }); + const text = String(stdout || "").trim(); + if (!text) { + throw new Error("MNote MCP client 未返回结果"); + } + try { + const payload = JSON.parse(text); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("返回值不是 JSON 对象"); + } + return payload as Record; + } catch (error) { + throw new Error( + `MNote MCP client 返回非 JSON: ${text.slice(0, 500)}` + + (error instanceof Error ? ` (${error.message})` : ""), + ); + } +} + +export default function mnoteMcpExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "mcp", + label: "MNote MCP", + description: "通过 Pi Rust 同步本地 client 调用当前 session 配置的 MCP 服务器。" + + "支持 list(列出工具)、status(检查连通性)、call(调用工具)三种模式。", + parameters: mcpToolParameters, + + async execute( + _toolCallId: string, + params: { server: string; mode: string; tool?: string; arguments?: Record }, + ): Promise { + const { server, mode, tool, arguments: args } = params; + if (!["list", "status", "call"].includes(mode)) { + return { + content: [{ type: "text", text: `无效 mode: "${mode}"。可选值: list, status, call` }], + isError: true, + }; + } + if (mode === "call" && !tool) { + return { + content: [{ type: "text", text: 'mode=call 时缺少必填参数 "tool"' }], + isError: true, + }; + } + + try { + const result = executeMcpRequest({ server, mode, tool, arguments: args }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + details: { + server, + mode, + tool: tool || null, + result, + transport: "pi-rust-sync-client", + }, + isError: result.ok === false, + }; + } catch (error) { + return { + content: [{ + type: "text", + text: `MCP 调用失败: ${error instanceof Error ? error.message : String(error)}`, + }], + isError: true, + }; + } + }, + }); +} diff --git a/packages/pi-mnote/extensions/pi-rust-official/permission-gate.ts b/packages/pi-mnote/extensions/pi-rust-official/permission-gate.ts new file mode 100644 index 00000000..0fc97c4d --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/permission-gate.ts @@ -0,0 +1,34 @@ +/** + * Permission Gate Extension + * + * Prompts for confirmation before running potentially dangerous bash commands. + * Patterns checked: rm -rf, sudo, chmod/chown 777 + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i]; + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return undefined; + + const command = event.input.command as string; + const isDangerous = dangerousPatterns.some((p) => p.test(command)); + + if (isDangerous) { + if (!ctx.hasUI) { + // In non-interactive mode, block by default + return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" }; + } + + const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]); + + if (choice !== "Yes") { + return { block: true, reason: "Blocked by user" }; + } + } + + return undefined; + }); +} diff --git a/packages/pi-mnote/extensions/pi-rust-official/plan-mode/README.md b/packages/pi-mnote/extensions/pi-rust-official/plan-mode/README.md new file mode 100644 index 00000000..549e3473 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/plan-mode/README.md @@ -0,0 +1,65 @@ +# Plan Mode Extension + +Read-only exploration mode for safe code analysis. + +## Features + +- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question +- **Bash allowlist**: Only read-only bash commands are allowed +- **Plan extraction**: Extracts numbered steps from `Plan:` sections +- **Progress tracking**: Widget shows completion status during execution +- **[DONE:n] markers**: Explicit step completion tracking +- **Session persistence**: State survives session resume + +## Commands + +- `/plan` - Toggle plan mode +- `/todos` - Show current plan progress +- `Ctrl+Alt+P` - Toggle plan mode (shortcut) + +## Usage + +1. Enable plan mode with `/plan` or `--plan` flag +2. Ask the agent to analyze code and create a plan +3. The agent should output a numbered plan under a `Plan:` header: + +``` +Plan: +1. First step description +2. Second step description +3. Third step description +``` + +4. Choose "Execute the plan" when prompted +5. During execution, the agent marks steps complete with `[DONE:n]` tags +6. Progress widget shows completion status + +## How It Works + +### Plan Mode (Read-Only) +- Only read-only tools available +- Bash commands filtered through allowlist +- Agent creates a plan without making changes + +### Execution Mode +- Full tool access restored +- Agent executes steps in order +- `[DONE:n]` markers track completion +- Widget shows progress + +### Command Allowlist + +Safe commands (allowed): +- File inspection: `cat`, `head`, `tail`, `less`, `more` +- Search: `grep`, `find`, `rg`, `fd` +- Directory: `ls`, `pwd`, `tree` +- Git read: `git status`, `git log`, `git diff`, `git branch` +- Package info: `npm list`, `npm outdated`, `yarn info` +- System info: `uname`, `whoami`, `date`, `uptime` + +Blocked commands: +- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch` +- Git write: `git add`, `git commit`, `git push` +- Package install: `npm install`, `yarn add`, `pip install` +- System: `sudo`, `kill`, `reboot` +- Editors: `vim`, `nano`, `code` diff --git a/packages/pi-mnote/extensions/pi-rust-official/plan-mode/index.ts b/packages/pi-mnote/extensions/pi-rust-official/plan-mode/index.ts new file mode 100644 index 00000000..0c77b9bd --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/plan-mode/index.ts @@ -0,0 +1,340 @@ +/** + * Plan Mode Extension + * + * Read-only exploration mode for safe code analysis. + * When enabled, only read-only tools are available. + * + * Features: + * - /plan command or Ctrl+Alt+P to toggle + * - Bash restricted to allowlisted read-only commands + * - Extracts numbered plan steps from "Plan:" sections + * - [DONE:n] markers to complete steps during execution + * - Progress tracking widget during execution + */ + +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { Key } from "@mariozechner/pi-tui"; +import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js"; + +// Tools +const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"]; +const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"]; + +// Type guard for assistant messages +function isAssistantMessage(m: AgentMessage): m is AssistantMessage { + return m.role === "assistant" && Array.isArray(m.content); +} + +// Extract text content from an assistant message +function getTextContent(message: AssistantMessage): string { + return message.content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +export default function planModeExtension(pi: ExtensionAPI): void { + let planModeEnabled = false; + let executionMode = false; + let todoItems: TodoItem[] = []; + + pi.registerFlag("plan", { + description: "Start in plan mode (read-only exploration)", + type: "boolean", + default: false, + }); + + function updateStatus(ctx: ExtensionContext): void { + // Footer status + if (executionMode && todoItems.length > 0) { + const completed = todoItems.filter((t) => t.completed).length; + ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`)); + } else if (planModeEnabled) { + ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan")); + } else { + ctx.ui.setStatus("plan-mode", undefined); + } + + // Widget showing todo list + if (executionMode && todoItems.length > 0) { + const lines = todoItems.map((item) => { + if (item.completed) { + return ( + ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)) + ); + } + return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`; + }); + ctx.ui.setWidget("plan-todos", lines); + } else { + ctx.ui.setWidget("plan-todos", undefined); + } + } + + function togglePlanMode(ctx: ExtensionContext): void { + planModeEnabled = !planModeEnabled; + executionMode = false; + todoItems = []; + + if (planModeEnabled) { + pi.setActiveTools(PLAN_MODE_TOOLS); + ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`); + } else { + pi.setActiveTools(NORMAL_MODE_TOOLS); + ctx.ui.notify("Plan mode disabled. Full access restored."); + } + updateStatus(ctx); + } + + function persistState(): void { + pi.appendEntry("plan-mode", { + enabled: planModeEnabled, + todos: todoItems, + executing: executionMode, + }); + } + + pi.registerCommand("plan", { + description: "Toggle plan mode (read-only exploration)", + handler: async (_args, ctx) => togglePlanMode(ctx), + }); + + pi.registerCommand("todos", { + description: "Show current plan todo list", + handler: async (_args, ctx) => { + if (todoItems.length === 0) { + ctx.ui.notify("No todos. Create a plan first with /plan", "info"); + return; + } + const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n"); + ctx.ui.notify(`Plan Progress:\n${list}`, "info"); + }, + }); + + pi.registerShortcut(Key.ctrlAlt("p"), { + description: "Toggle plan mode", + handler: async (ctx) => togglePlanMode(ctx), + }); + + // Block destructive bash commands in plan mode + pi.on("tool_call", async (event) => { + if (!planModeEnabled || event.toolName !== "bash") return; + + const command = event.input.command as string; + if (!isSafeCommand(command)) { + return { + block: true, + reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`, + }; + } + }); + + // Filter out stale plan mode context when not in plan mode + pi.on("context", async (event) => { + if (planModeEnabled) return; + + return { + messages: event.messages.filter((m) => { + const msg = m as AgentMessage & { customType?: string }; + if (msg.customType === "plan-mode-context") return false; + if (msg.role !== "user") return true; + + const content = msg.content; + if (typeof content === "string") { + return !content.includes("[PLAN MODE ACTIVE]"); + } + if (Array.isArray(content)) { + return !content.some( + (c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"), + ); + } + return true; + }), + }; + }); + + // Inject plan/execution context before agent starts + pi.on("before_agent_start", async () => { + if (planModeEnabled) { + return { + message: { + customType: "plan-mode-context", + content: `[PLAN MODE ACTIVE] +You are in plan mode - a read-only exploration mode for safe code analysis. + +Restrictions: +- You can only use: read, bash, grep, find, ls, questionnaire +- You CANNOT use: edit, write (file modifications are disabled) +- Bash is restricted to an allowlist of read-only commands + +Ask clarifying questions using the questionnaire tool. +Use brave-search skill via bash for web research. + +Create a detailed numbered plan under a "Plan:" header: + +Plan: +1. First step description +2. Second step description +... + +Do NOT attempt to make changes - just describe what you would do.`, + display: false, + }, + }; + } + + if (executionMode && todoItems.length > 0) { + const remaining = todoItems.filter((t) => !t.completed); + const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n"); + return { + message: { + customType: "plan-execution-context", + content: `[EXECUTING PLAN - Full tool access enabled] + +Remaining steps: +${todoList} + +Execute each step in order. +After completing a step, include a [DONE:n] tag in your response.`, + display: false, + }, + }; + } + }); + + // Track progress after each turn + pi.on("turn_end", async (event, ctx) => { + if (!executionMode || todoItems.length === 0) return; + if (!isAssistantMessage(event.message)) return; + + const text = getTextContent(event.message); + if (markCompletedSteps(text, todoItems) > 0) { + updateStatus(ctx); + } + persistState(); + }); + + // Handle plan completion and plan mode UI + pi.on("agent_end", async (event, ctx) => { + // Check if execution is complete + if (executionMode && todoItems.length > 0) { + if (todoItems.every((t) => t.completed)) { + const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n"); + pi.sendMessage( + { customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, + { triggerTurn: false }, + ); + executionMode = false; + todoItems = []; + pi.setActiveTools(NORMAL_MODE_TOOLS); + updateStatus(ctx); + persistState(); // Save cleared state so resume doesn't restore old execution mode + } + return; + } + + if (!planModeEnabled || !ctx.hasUI) return; + + // Extract todos from last assistant message + const lastAssistant = [...event.messages].reverse().find(isAssistantMessage); + if (lastAssistant) { + const extracted = extractTodoItems(getTextContent(lastAssistant)); + if (extracted.length > 0) { + todoItems = extracted; + } + } + + // Show plan steps and prompt for next action + if (todoItems.length > 0) { + const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n"); + pi.sendMessage( + { + customType: "plan-todo-list", + content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`, + display: true, + }, + { triggerTurn: false }, + ); + } + + const choice = await ctx.ui.select("Plan mode - what next?", [ + todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan", + "Stay in plan mode", + "Refine the plan", + ]); + + if (choice?.startsWith("Execute")) { + planModeEnabled = false; + executionMode = todoItems.length > 0; + pi.setActiveTools(NORMAL_MODE_TOOLS); + updateStatus(ctx); + + const execMessage = + todoItems.length > 0 + ? `Execute the plan. Start with: ${todoItems[0].text}` + : "Execute the plan you just created."; + pi.sendMessage( + { customType: "plan-mode-execute", content: execMessage, display: true }, + { triggerTurn: true }, + ); + } else if (choice === "Refine the plan") { + const refinement = await ctx.ui.editor("Refine the plan:", ""); + if (refinement?.trim()) { + pi.sendUserMessage(refinement.trim()); + } + } + }); + + // Restore state on session start/resume + pi.on("session_start", async (_event, ctx) => { + if (pi.getFlag("plan") === true) { + planModeEnabled = true; + } + + const entries = ctx.sessionManager.getEntries(); + + // Restore persisted state + const planModeEntry = entries + .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode") + .pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined; + + if (planModeEntry?.data) { + planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled; + todoItems = planModeEntry.data.todos ?? todoItems; + executionMode = planModeEntry.data.executing ?? executionMode; + } + + // On resume: re-scan messages to rebuild completion state + // Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans + const isResume = planModeEntry !== undefined; + if (isResume && executionMode && todoItems.length > 0) { + // Find the index of the last plan-mode-execute entry (marks when current execution started) + let executeIndex = -1; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] as { type: string; customType?: string }; + if (entry.customType === "plan-mode-execute") { + executeIndex = i; + break; + } + } + + // Only scan messages after the execute marker + const messages: AssistantMessage[] = []; + for (let i = executeIndex + 1; i < entries.length; i++) { + const entry = entries[i]; + if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) { + messages.push(entry.message as AssistantMessage); + } + } + const allText = messages.map(getTextContent).join("\n"); + markCompletedSteps(allText, todoItems); + } + + if (planModeEnabled) { + pi.setActiveTools(PLAN_MODE_TOOLS); + } + updateStatus(ctx); + }); +} diff --git a/packages/pi-mnote/extensions/pi-rust-official/plan-mode/utils.ts b/packages/pi-mnote/extensions/pi-rust-official/plan-mode/utils.ts new file mode 100644 index 00000000..7c49bdb6 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/plan-mode/utils.ts @@ -0,0 +1,168 @@ +/** + * Pure utility functions for plan mode. + * Extracted for testability. + */ + +// Destructive commands blocked in plan mode +const DESTRUCTIVE_PATTERNS = [ + /\brm\b/i, + /\brmdir\b/i, + /\bmv\b/i, + /\bcp\b/i, + /\bmkdir\b/i, + /\btouch\b/i, + /\bchmod\b/i, + /\bchown\b/i, + /\bchgrp\b/i, + /\bln\b/i, + /\btee\b/i, + /\btruncate\b/i, + /\bdd\b/i, + /\bshred\b/i, + /(^|[^<])>(?!>)/, + />>/, + /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, + /\byarn\s+(add|remove|install|publish)/i, + /\bpnpm\s+(add|remove|install|publish)/i, + /\bpip\s+(install|uninstall)/i, + /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, + /\bbrew\s+(install|uninstall|upgrade)/i, + /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, + /\bsudo\b/i, + /\bsu\b/i, + /\bkill\b/i, + /\bpkill\b/i, + /\bkillall\b/i, + /\breboot\b/i, + /\bshutdown\b/i, + /\bsystemctl\s+(start|stop|restart|enable|disable)/i, + /\bservice\s+\S+\s+(start|stop|restart)/i, + /\b(vim?|nano|emacs|code|subl)\b/i, +]; + +// Safe read-only commands allowed in plan mode +const SAFE_PATTERNS = [ + /^\s*cat\b/, + /^\s*head\b/, + /^\s*tail\b/, + /^\s*less\b/, + /^\s*more\b/, + /^\s*grep\b/, + /^\s*find\b/, + /^\s*ls\b/, + /^\s*pwd\b/, + /^\s*echo\b/, + /^\s*printf\b/, + /^\s*wc\b/, + /^\s*sort\b/, + /^\s*uniq\b/, + /^\s*diff\b/, + /^\s*file\b/, + /^\s*stat\b/, + /^\s*du\b/, + /^\s*df\b/, + /^\s*tree\b/, + /^\s*which\b/, + /^\s*whereis\b/, + /^\s*type\b/, + /^\s*env\b/, + /^\s*printenv\b/, + /^\s*uname\b/, + /^\s*whoami\b/, + /^\s*id\b/, + /^\s*date\b/, + /^\s*cal\b/, + /^\s*uptime\b/, + /^\s*ps\b/, + /^\s*top\b/, + /^\s*htop\b/, + /^\s*free\b/, + /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, + /^\s*git\s+ls-/i, + /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, + /^\s*yarn\s+(list|info|why|audit)/i, + /^\s*node\s+--version/i, + /^\s*python\s+--version/i, + /^\s*curl\s/i, + /^\s*wget\s+-O\s*-/i, + /^\s*jq\b/, + /^\s*sed\s+-n/i, + /^\s*awk\b/, + /^\s*rg\b/, + /^\s*fd\b/, + /^\s*bat\b/, + /^\s*exa\b/, +]; + +export function isSafeCommand(command: string): boolean { + const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command)); + const isSafe = SAFE_PATTERNS.some((p) => p.test(command)); + return !isDestructive && isSafe; +} + +export interface TodoItem { + step: number; + text: string; + completed: boolean; +} + +export function cleanStepText(text: string): string { + let cleaned = text + .replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic + .replace(/`([^`]+)`/g, "$1") // Remove code + .replace( + /^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i, + "", + ) + .replace(/\s+/g, " ") + .trim(); + + if (cleaned.length > 0) { + cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1); + } + if (cleaned.length > 50) { + cleaned = `${cleaned.slice(0, 47)}...`; + } + return cleaned; +} + +export function extractTodoItems(message: string): TodoItem[] { + const items: TodoItem[] = []; + const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i); + if (!headerMatch) return items; + + const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length); + const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm; + + for (const match of planSection.matchAll(numberedPattern)) { + const text = match[2] + .trim() + .replace(/\*{1,2}$/, "") + .trim(); + if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) { + const cleaned = cleanStepText(text); + if (cleaned.length > 3) { + items.push({ step: items.length + 1, text: cleaned, completed: false }); + } + } + } + return items; +} + +export function extractDoneSteps(message: string): number[] { + const steps: number[] = []; + for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) { + const step = Number(match[1]); + if (Number.isFinite(step)) steps.push(step); + } + return steps; +} + +export function markCompletedSteps(text: string, items: TodoItem[]): number { + const doneSteps = extractDoneSteps(text); + for (const step of doneSteps) { + const item = items.find((t) => t.step === step); + if (item) item.completed = true; + } + return doneSteps.length; +} diff --git a/packages/pi-mnote/extensions/pi-rust-official/question.ts b/packages/pi-mnote/extensions/pi-rust-official/question.ts new file mode 100644 index 00000000..cfbe16e1 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/question.ts @@ -0,0 +1,283 @@ +/** + * Question Tool - Single question with options + * Full custom UI: options list + inline editor for "Type something..." + * Escape in editor returns to options, Escape in options cancels + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +interface OptionWithDesc { + label: string; + description?: string; +} + +type DisplayOption = OptionWithDesc & { isOther?: boolean }; + +interface QuestionDetails { + question: string; + options: string[]; + answer: string | null; + wasCustom?: boolean; +} + +// Options with labels and optional descriptions +const OptionSchema = Type.Object({ + label: Type.String({ description: "Display label for the option" }), + description: Type.Optional(Type.String({ description: "Optional description shown below label" })), +}); + +const QuestionParams = Type.Object({ + question: Type.String({ description: "The question to ask the user" }), + options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }), +}); + +export default function question(pi: ExtensionAPI) { + pi.registerTool({ + name: "question", + label: "Question", + description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.", + parameters: QuestionParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!ctx.hasUI) { + return { + content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], + details: { + question: params.question, + options: params.options.map((o) => o.label), + answer: null, + } as QuestionDetails, + }; + } + + if (params.options.length === 0) { + return { + content: [{ type: "text", text: "Error: No options provided" }], + details: { question: params.question, options: [], answer: null } as QuestionDetails, + }; + } + + const simpleOptions = params.options.map((o) => o.label); + if (ctx.ui && typeof ctx.ui.select === "function") { + const answer = await ctx.ui.select(params.question, simpleOptions); + if (answer === undefined || answer === null) { + return { + content: [{ type: "text", text: "User cancelled the selection" }], + details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails, + }; + } + const selected = String(answer); + const selectedIndex = simpleOptions.indexOf(selected); + return { + content: [{ type: "text", text: `User selected: ${selectedIndex + 1}. ${selected}` }], + details: { + question: params.question, + options: simpleOptions, + answer: selected, + wasCustom: false, + } as QuestionDetails, + }; + } + + const allOptions: DisplayOption[] = [...params.options, { label: "Type something.", isOther: true }]; + + const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>( + (tui, theme, _kb, done) => { + let optionIndex = 0; + let editMode = false; + let cachedLines: string[] | undefined; + + const editorTheme: EditorTheme = { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme); + + editor.onSubmit = (value) => { + const trimmed = value.trim(); + if (trimmed) { + done({ answer: trimmed, wasCustom: true }); + } else { + editMode = false; + editor.setText(""); + refresh(); + } + }; + + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function handleInput(data: string) { + if (editMode) { + if (matchesKey(data, Key.escape)) { + editMode = false; + editor.setText(""); + refresh(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(allOptions.length - 1, optionIndex + 1); + refresh(); + return; + } + + if (matchesKey(data, Key.enter)) { + const selected = allOptions[optionIndex]; + if (selected.isOther) { + editMode = true; + refresh(); + } else { + done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 }); + } + return; + } + + if (matchesKey(data, Key.escape)) { + done(null); + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const lines: string[] = []; + const add = (s: string) => lines.push(truncateToWidth(s, width)); + + add(theme.fg("accent", "─".repeat(width))); + add(theme.fg("text", ` ${params.question}`)); + lines.push(""); + + for (let i = 0; i < allOptions.length; i++) { + const opt = allOptions[i]; + const selected = i === optionIndex; + const isOther = opt.isOther === true; + const prefix = selected ? theme.fg("accent", "> ") : " "; + + if (isOther && editMode) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); + } else if (selected) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`)); + } else { + add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`); + } + + // Show description if present + if (opt.description) { + add(` ${theme.fg("muted", opt.description)}`); + } + } + + if (editMode) { + lines.push(""); + add(theme.fg("muted", " Your answer:")); + for (const line of editor.render(width - 2)) { + add(` ${line}`); + } + } + + lines.push(""); + if (editMode) { + add(theme.fg("dim", " Enter to submit • Esc to go back")); + } else { + add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel")); + } + add(theme.fg("accent", "─".repeat(width))); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }, + ); + + if (!result) { + return { + content: [{ type: "text", text: "User cancelled the selection" }], + details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails, + }; + } + + if (result.wasCustom) { + return { + content: [{ type: "text", text: `User wrote: ${result.answer}` }], + details: { + question: params.question, + options: simpleOptions, + answer: result.answer, + wasCustom: true, + } as QuestionDetails, + }; + } + return { + content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }], + details: { + question: params.question, + options: simpleOptions, + answer: result.answer, + wasCustom: false, + } as QuestionDetails, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question); + const opts = Array.isArray(args.options) ? args.options : []; + if (opts.length) { + const labels = opts.map((o: OptionWithDesc) => o.label); + const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`); + text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`; + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as QuestionDetails | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (details.answer === null) { + return new Text(theme.fg("warning", "Cancelled"), 0, 0); + } + + if (details.wasCustom) { + return new Text( + theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer), + 0, + 0, + ); + } + const idx = details.options.indexOf(details.answer) + 1; + const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer; + return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0); + }, + }); +} diff --git a/packages/pi-mnote/extensions/pi-rust-official/questionnaire.ts b/packages/pi-mnote/extensions/pi-rust-official/questionnaire.ts new file mode 100644 index 00000000..721eb049 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/questionnaire.ts @@ -0,0 +1,453 @@ +/** + * Questionnaire Tool - Unified tool for asking single or multiple questions + * + * Single question: simple options list + * Multiple questions: tab bar navigation between questions + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +// Types +interface QuestionOption { + value: string; + label: string; + description?: string; +} + +type RenderOption = QuestionOption & { isOther?: boolean }; + +interface Question { + id: string; + label: string; + prompt: string; + options: QuestionOption[]; + allowOther: boolean; +} + +interface Answer { + id: string; + value: string; + label: string; + wasCustom: boolean; + index?: number; +} + +interface QuestionnaireResult { + questions: Question[]; + answers: Answer[]; + cancelled: boolean; +} + +// Schema +const QuestionOptionSchema = Type.Object({ + value: Type.String({ description: "The value returned when selected" }), + label: Type.String({ description: "Display label for the option" }), + description: Type.Optional(Type.String({ description: "Optional description shown below label" })), +}); + +const QuestionSchema = Type.Object({ + id: Type.String({ description: "Unique identifier for this question" }), + label: Type.Optional( + Type.String({ + description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)", + }), + ), + prompt: Type.String({ description: "The full question text to display" }), + options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }), + allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })), +}); + +const QuestionnaireParams = Type.Object({ + questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }), +}); + +function errorResult( + message: string, + questions: Question[] = [], +): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } { + return { + content: [{ type: "text", text: message }], + details: { questions, answers: [], cancelled: true }, + }; +} + +export default function questionnaire(pi: ExtensionAPI) { + pi.registerTool({ + name: "questionnaire", + label: "Questionnaire", + description: + "Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.", + parameters: QuestionnaireParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (!ctx.hasUI) { + return errorResult("Error: UI not available (running in non-interactive mode)"); + } + if (params.questions.length === 0) { + return errorResult("Error: No questions provided"); + } + + // Normalize questions with defaults + const questions: Question[] = params.questions.map((q, i) => ({ + ...q, + label: q.label || `Q${i + 1}`, + allowOther: q.allowOther !== false, + })); + + if (questions.length === 1 && ctx.ui && typeof ctx.ui.select === "function") { + const q = questions[0]; + const labels = q.options.map((option) => option.label); + const answer = await ctx.ui.select(q.prompt, labels); + if (answer === undefined || answer === null) { + return errorResult("User cancelled the selection", questions); + } + const selected = String(answer); + const optionIndex = labels.indexOf(selected); + const option = optionIndex >= 0 ? q.options[optionIndex] : undefined; + return { + content: [{ type: "text", text: `User selected: ${selected}` }], + details: { + questions, + answers: [{ + id: q.id, + value: option ? option.value : selected, + label: selected, + wasCustom: false, + index: optionIndex >= 0 ? optionIndex + 1 : undefined, + }], + cancelled: false, + } as QuestionnaireResult, + }; + } + + const isMulti = questions.length > 1; + const totalTabs = questions.length + 1; // questions + Submit + + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + // State + let currentTab = 0; + let optionIndex = 0; + let inputMode = false; + let inputQuestionId: string | null = null; + let cachedLines: string[] | undefined; + const answers = new Map(); + + // Editor for "Type something" option + const editorTheme: EditorTheme = { + borderColor: (s) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t) => theme.fg("accent", t), + selectedText: (t) => theme.fg("accent", t), + description: (t) => theme.fg("muted", t), + scrollInfo: (t) => theme.fg("dim", t), + noMatch: (t) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme); + + // Helpers + function refresh() { + cachedLines = undefined; + tui.requestRender(); + } + + function submit(cancelled: boolean) { + done({ questions, answers: Array.from(answers.values()), cancelled }); + } + + function currentQuestion(): Question | undefined { + return questions[currentTab]; + } + + function currentOptions(): RenderOption[] { + const q = currentQuestion(); + if (!q) return []; + const opts: RenderOption[] = [...q.options]; + if (q.allowOther) { + opts.push({ value: "__other__", label: "Type something.", isOther: true }); + } + return opts; + } + + function allAnswered(): boolean { + return questions.every((q) => answers.has(q.id)); + } + + function advanceAfterAnswer() { + if (!isMulti) { + submit(false); + return; + } + if (currentTab < questions.length - 1) { + currentTab++; + } else { + currentTab = questions.length; // Submit tab + } + optionIndex = 0; + refresh(); + } + + function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) { + answers.set(questionId, { id: questionId, value, label, wasCustom, index }); + } + + // Editor submit callback + editor.onSubmit = (value) => { + if (!inputQuestionId) return; + const trimmed = value.trim() || "(no response)"; + saveAnswer(inputQuestionId, trimmed, trimmed, true); + inputMode = false; + inputQuestionId = null; + editor.setText(""); + advanceAfterAnswer(); + }; + + function handleInput(data: string) { + // Input mode: route to editor + if (inputMode) { + if (matchesKey(data, Key.escape)) { + inputMode = false; + inputQuestionId = null; + editor.setText(""); + refresh(); + return; + } + editor.handleInput(data); + refresh(); + return; + } + + const q = currentQuestion(); + const opts = currentOptions(); + + // Tab navigation (multi-question only) + if (isMulti) { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + currentTab = (currentTab + 1) % totalTabs; + optionIndex = 0; + refresh(); + return; + } + if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + currentTab = (currentTab - 1 + totalTabs) % totalTabs; + optionIndex = 0; + refresh(); + return; + } + } + + // Submit tab + if (currentTab === questions.length) { + if (matchesKey(data, Key.enter) && allAnswered()) { + submit(false); + } else if (matchesKey(data, Key.escape)) { + submit(true); + } + return; + } + + // Option navigation + if (matchesKey(data, Key.up)) { + optionIndex = Math.max(0, optionIndex - 1); + refresh(); + return; + } + if (matchesKey(data, Key.down)) { + optionIndex = Math.min(opts.length - 1, optionIndex + 1); + refresh(); + return; + } + + // Select option + if (matchesKey(data, Key.enter) && q) { + const opt = opts[optionIndex]; + if (opt.isOther) { + inputMode = true; + inputQuestionId = q.id; + editor.setText(""); + refresh(); + return; + } + saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1); + advanceAfterAnswer(); + return; + } + + // Cancel + if (matchesKey(data, Key.escape)) { + submit(true); + } + } + + function render(width: number): string[] { + if (cachedLines) return cachedLines; + + const lines: string[] = []; + const q = currentQuestion(); + const opts = currentOptions(); + + // Helper to add truncated line + const add = (s: string) => lines.push(truncateToWidth(s, width)); + + add(theme.fg("accent", "─".repeat(width))); + + // Tab bar (multi-question only) + if (isMulti) { + const tabs: string[] = ["← "]; + for (let i = 0; i < questions.length; i++) { + const isActive = i === currentTab; + const isAnswered = answers.has(questions[i].id); + const lbl = questions[i].label; + const box = isAnswered ? "■" : "□"; + const color = isAnswered ? "success" : "muted"; + const text = ` ${box} ${lbl} `; + const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text); + tabs.push(`${styled} `); + } + const canSubmit = allAnswered(); + const isSubmitTab = currentTab === questions.length; + const submitText = " ✓ Submit "; + const submitStyled = isSubmitTab + ? theme.bg("selectedBg", theme.fg("text", submitText)) + : theme.fg(canSubmit ? "success" : "dim", submitText); + tabs.push(`${submitStyled} →`); + add(` ${tabs.join("")}`); + lines.push(""); + } + + // Helper to render options list + function renderOptions() { + for (let i = 0; i < opts.length; i++) { + const opt = opts[i]; + const selected = i === optionIndex; + const isOther = opt.isOther === true; + const prefix = selected ? theme.fg("accent", "> ") : " "; + const color = selected ? "accent" : "text"; + // Mark "Type something" differently when in input mode + if (isOther && inputMode) { + add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`)); + } else { + add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`)); + } + if (opt.description) { + add(` ${theme.fg("muted", opt.description)}`); + } + } + } + + // Content + if (inputMode && q) { + add(theme.fg("text", ` ${q.prompt}`)); + lines.push(""); + // Show options for reference + renderOptions(); + lines.push(""); + add(theme.fg("muted", " Your answer:")); + for (const line of editor.render(width - 2)) { + add(` ${line}`); + } + lines.push(""); + add(theme.fg("dim", " Enter to submit • Esc to cancel")); + } else if (currentTab === questions.length) { + add(theme.fg("accent", theme.bold(" Ready to submit"))); + lines.push(""); + for (const question of questions) { + const answer = answers.get(question.id); + if (answer) { + const prefix = answer.wasCustom ? "(wrote) " : ""; + add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`); + } + } + lines.push(""); + if (allAnswered()) { + add(theme.fg("success", " Press Enter to submit")); + } else { + const missing = questions + .filter((q) => !answers.has(q.id)) + .map((q) => q.label) + .join(", "); + add(theme.fg("warning", ` Unanswered: ${missing}`)); + } + } else if (q) { + add(theme.fg("text", ` ${q.prompt}`)); + lines.push(""); + renderOptions(); + } + + lines.push(""); + if (!inputMode) { + const help = isMulti + ? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel" + : " ↑↓ navigate • Enter select • Esc cancel"; + add(theme.fg("dim", help)); + } + add(theme.fg("accent", "─".repeat(width))); + + cachedLines = lines; + return lines; + } + + return { + render, + invalidate: () => { + cachedLines = undefined; + }, + handleInput, + }; + }); + + if (result.cancelled) { + return { + content: [{ type: "text", text: "User cancelled the questionnaire" }], + details: result, + }; + } + + const answerLines = result.answers.map((a) => { + const qLabel = questions.find((q) => q.id === a.id)?.label || a.id; + if (a.wasCustom) { + return `${qLabel}: user wrote: ${a.label}`; + } + return `${qLabel}: user selected: ${a.index}. ${a.label}`; + }); + + return { + content: [{ type: "text", text: answerLines.join("\n") }], + details: result, + }; + }, + + renderCall(args, theme) { + const qs = (args.questions as Question[]) || []; + const count = qs.length; + const labels = qs.map((q) => q.label || q.id).join(", "); + let text = theme.fg("toolTitle", theme.bold("questionnaire ")); + text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`); + if (labels) { + text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`); + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details as QuestionnaireResult | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + if (details.cancelled) { + return new Text(theme.fg("warning", "Cancelled"), 0, 0); + } + const lines = details.answers.map((a) => { + if (a.wasCustom) { + return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`; + } + const display = a.index ? `${a.index}. ${a.label}` : a.label; + return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`; + }); + return new Text(lines.join("\n"), 0, 0); + }, + }); +} diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/README.md b/packages/pi-mnote/extensions/pi-rust-official/subagent/README.md new file mode 100644 index 00000000..8599679f --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/README.md @@ -0,0 +1,172 @@ +# Subagent Example + +Delegate tasks to specialized subagents with isolated context windows. + +## Features + +- **Isolated context**: Each subagent runs in a separate `pi` process +- **Streaming output**: See tool calls and progress as they happen +- **Parallel streaming**: All parallel tasks stream updates simultaneously +- **Markdown rendering**: Final output rendered with proper formatting (expanded view) +- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent +- **Abort support**: Ctrl+C propagates to kill subagent processes + +## Structure + +``` +subagent/ +├── README.md # This file +├── index.ts # The extension (entry point) +├── agents.ts # Agent discovery logic +├── agents/ # Sample agent definitions +│ ├── scout.md # Fast recon, returns compressed context +│ ├── planner.md # Creates implementation plans +│ ├── reviewer.md # Code review +│ └── worker.md # General-purpose (full capabilities) +└── prompts/ # Workflow presets (prompt templates) + ├── implement.md # scout -> planner -> worker + ├── scout-and-plan.md # scout -> planner (no implementation) + └── implement-and-review.md # worker -> reviewer -> worker +``` + +## Installation + +From the repository root, symlink the files: + +```bash +# Symlink the extension (must be in a subdirectory with index.ts) +mkdir -p ~/.pi/agent/extensions/subagent +ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts +ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts + +# Symlink agents +mkdir -p ~/.pi/agent/agents +for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do + ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f") +done + +# Symlink workflow prompts +mkdir -p ~/.pi/agent/prompts +for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do + ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f") +done +``` + +## Security Model + +This tool executes a separate `pi` subprocess with a delegated system prompt and tool/model configuration. + +**Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc. + +**Default behavior:** Only loads **user-level agents** from `~/.pi/agent/agents`. + +To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust. + +When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable. + +## Usage + +### Single agent +``` +Use scout to find all authentication code +``` + +### Parallel execution +``` +Run 2 scouts in parallel: one to find models, one to find providers +``` + +### Chained workflow +``` +Use a chain: first have scout find the read tool, then have planner suggest improvements +``` + +### Workflow prompts +``` +/implement add Redis caching to the session store +/scout-and-plan refactor auth to support OAuth +/implement-and-review add input validation to API endpoints +``` + +## Tool Modes + +| Mode | Parameter | Description | +|------|-----------|-------------| +| Single | `{ agent, task }` | One agent, one task | +| Parallel | `{ tasks: [...] }` | Multiple agents run concurrently (max 8, 4 concurrent) | +| Chain | `{ chain: [...] }` | Sequential with `{previous}` placeholder | + +## Output Display + +**Collapsed view** (default): +- Status icon (✓/✗/⏳) and agent name +- Last 5-10 items (tool calls and text) +- Usage stats: `3 turns ↑input ↓output RcacheRead WcacheWrite $cost ctx:contextTokens model` + +**Expanded view** (Ctrl+O): +- Full task text +- All tool calls with formatted arguments +- Final output rendered as Markdown +- Per-task usage (for chain/parallel) + +**Parallel mode streaming**: +- Shows all tasks with live status (⏳ running, ✓ done, ✗ failed) +- Updates as each task makes progress +- Shows "2/3 done, 1 running" status + +**Tool call formatting** (mimics built-in tools): +- `$ command` for bash +- `read ~/path:1-10` for read +- `grep /pattern/ in ~/path` for grep +- etc. + +## Agent Definitions + +Agents are markdown files with YAML frontmatter: + +```markdown +--- +name: my-agent +description: What this agent does +tools: read, grep, find, ls +model: claude-haiku-4-5 +--- + +System prompt for the agent goes here. +``` + +**Locations:** +- `~/.pi/agent/agents/*.md` - User-level (always loaded) +- `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`) + +Project agents override user agents with the same name when `agentScope: "both"`. + +## Sample Agents + +| Agent | Purpose | Model | Tools | +|-------|---------|-------|-------| +| `scout` | Fast codebase recon | Haiku | read, grep, find, ls, bash | +| `planner` | Implementation plans | Sonnet | read, grep, find, ls | +| `reviewer` | Code review | Sonnet | read, grep, find, ls, bash | +| `worker` | General-purpose | Sonnet | (all default) | + +## Workflow Prompts + +| Prompt | Flow | +|--------|------| +| `/implement ` | scout → planner → worker | +| `/scout-and-plan ` | scout → planner | +| `/implement-and-review ` | worker → reviewer → worker | + +## Error Handling + +- **Exit code != 0**: Tool returns error with stderr/output +- **stopReason "error"**: LLM error propagated with error message +- **stopReason "aborted"**: User abort (Ctrl+C) kills subprocess, throws error +- **Chain mode**: Stops at first failing step, reports which step failed + +## Limitations + +- Output truncated to last 10 items in collapsed view (expand to see all) +- Agents discovered fresh on each invocation (allows editing mid-session) +- Parallel mode limited to 8 tasks, 4 concurrent diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/agents.ts b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents.ts new file mode 100644 index 00000000..ae74e361 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents.ts @@ -0,0 +1,127 @@ +/** + * Agent discovery and configuration + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parseFrontmatter } from "@mariozechner/pi-coding-agent"; + +export type AgentScope = "user" | "project" | "both"; + +export interface AgentConfig { + name: string; + description: string; + tools?: string[]; + model?: string; + systemPrompt: string; + source: "user" | "project"; + filePath: string; +} + +export interface AgentDiscoveryResult { + agents: AgentConfig[]; + projectAgentsDir: string | null; +} + +function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] { + const agents: AgentConfig[] = []; + + if (!fs.existsSync(dir)) { + return agents; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return agents; + } + + for (const entry of entries) { + if (!entry.name.endsWith(".md")) continue; + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + + const filePath = path.join(dir, entry.name); + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const { frontmatter, body } = parseFrontmatter>(content); + + if (!frontmatter.name || !frontmatter.description) { + continue; + } + + const tools = frontmatter.tools + ?.split(",") + .map((t: string) => t.trim()) + .filter(Boolean); + + agents.push({ + name: frontmatter.name, + description: frontmatter.description, + tools: tools && tools.length > 0 ? tools : undefined, + model: frontmatter.model, + systemPrompt: body, + source, + filePath, + }); + } + + return agents; +} + +function isDirectory(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +function findNearestProjectAgentsDir(cwd: string): string | null { + let currentDir = cwd; + while (true) { + const candidate = path.join(currentDir, ".pi", "agents"); + if (isDirectory(candidate)) return candidate; + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult { + const userDir = path.join(os.homedir(), ".pi", "agent", "agents"); + const projectAgentsDir = findNearestProjectAgentsDir(cwd); + + const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user"); + const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project"); + + const agentMap = new Map(); + + if (scope === "both") { + for (const agent of userAgents) agentMap.set(agent.name, agent); + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } else if (scope === "user") { + for (const agent of userAgents) agentMap.set(agent.name, agent); + } else { + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } + + return { agents: Array.from(agentMap.values()), projectAgentsDir }; +} + +export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } { + if (agents.length === 0) return { text: "none", remaining: 0 }; + const listed = agents.slice(0, maxItems); + const remaining = agents.length - listed.length; + return { + text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "), + remaining, + }; +} diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/planner.md b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/planner.md new file mode 100644 index 00000000..7acc7187 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/planner.md @@ -0,0 +1,37 @@ +--- +name: planner +description: Creates implementation plans from context and requirements +tools: read, grep, find, ls +model: claude-sonnet-4-5 +--- + +You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan. + +You must NOT make any changes. Only read, analyze, and plan. + +Input format you'll receive: +- Context/findings from a scout agent +- Original query or requirements + +Output format: + +## Goal +One sentence summary of what needs to be done. + +## Plan +Numbered steps, each small and actionable: +1. Step one - specific file/function to modify +2. Step two - what to add/change +3. ... + +## Files to Modify +- `path/to/file.ts` - what changes +- `path/to/other.ts` - what changes + +## New Files (if any) +- `path/to/new.ts` - purpose + +## Risks +Anything to watch out for. + +Keep the plan concrete. The worker agent will execute it verbatim. diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/reviewer.md b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/reviewer.md new file mode 100644 index 00000000..a6706993 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/reviewer.md @@ -0,0 +1,35 @@ +--- +name: reviewer +description: Code review specialist for quality and security analysis +tools: read, grep, find, ls, bash +model: claude-sonnet-4-5 +--- + +You are a senior code reviewer. Analyze code for quality, security, and maintainability. + +Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds. +Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only. + +Strategy: +1. Run `git diff` to see recent changes (if applicable) +2. Read the modified files +3. Check for bugs, security issues, code smells + +Output format: + +## Files Reviewed +- `path/to/file.ts` (lines X-Y) + +## Critical (must fix) +- `file.ts:42` - Issue description + +## Warnings (should fix) +- `file.ts:100` - Issue description + +## Suggestions (consider) +- `file.ts:150` - Improvement idea + +## Summary +Overall assessment in 2-3 sentences. + +Be specific with file paths and line numbers. diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/scout.md b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/scout.md new file mode 100644 index 00000000..c59611b7 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/scout.md @@ -0,0 +1,50 @@ +--- +name: scout +description: Fast codebase recon that returns compressed context for handoff to other agents +tools: read, grep, find, ls, bash +model: claude-haiku-4-5 +--- + +You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything. + +Your output will be passed to an agent who has NOT seen the files you explored. + +Thoroughness (infer from task, default medium): +- Quick: Targeted lookups, key files only +- Medium: Follow imports, read critical sections +- Thorough: Trace all dependencies, check tests/types + +Strategy: +1. grep/find to locate relevant code +2. Read key sections (not entire files) +3. Identify types, interfaces, key functions +4. Note dependencies between files + +Output format: + +## Files Retrieved +List with exact line ranges: +1. `path/to/file.ts` (lines 10-50) - Description of what's here +2. `path/to/other.ts` (lines 100-150) - Description +3. ... + +## Key Code +Critical types, interfaces, or functions: + +```typescript +interface Example { + // actual code from the files +} +``` + +```typescript +function keyFunction() { + // actual implementation +} +``` + +## Architecture +Brief explanation of how the pieces connect. + +## Start Here +Which file to look at first and why. diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/worker.md b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/worker.md new file mode 100644 index 00000000..d9688355 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/agents/worker.md @@ -0,0 +1,24 @@ +--- +name: worker +description: General-purpose subagent with full capabilities, isolated context +model: claude-sonnet-4-5 +--- + +You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation. + +Work autonomously to complete the assigned task. Use all available tools as needed. + +Output format when finished: + +## Completed +What was done. + +## Files Changed +- `path/to/file.ts` - what changed + +## Notes (if any) +Anything the main agent should know. + +If handing off to another agent (e.g. reviewer), include: +- Exact file paths changed +- Key functions/types touched (short list) diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/index.ts b/packages/pi-mnote/extensions/pi-rust-official/subagent/index.ts new file mode 100644 index 00000000..1c43cbfc --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/index.ts @@ -0,0 +1,963 @@ +/** + * Subagent Tool - Delegate tasks to specialized agents + * + * Spawns a separate `pi` process for each subagent invocation, + * giving it an isolated context window. + * + * Supports three modes: + * - Single: { agent: "name", task: "..." } + * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] } + * - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] } + * + * Uses JSON mode to capture structured output from subagents. + */ + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { Message } from "@mariozechner/pi-ai"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js"; + +const MAX_PARALLEL_TASKS = 8; +const MAX_CONCURRENCY = 4; +const COLLAPSED_ITEM_COUNT = 10; + +function formatTokens(count: number): string { + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + return `${(count / 1000000).toFixed(1)}M`; +} + +function formatUsageStats( + usage: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + contextTokens?: number; + turns?: number; + }, + model?: string, +): string { + const parts: string[] = []; + if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`); + if (usage.input) parts.push(`↑${formatTokens(usage.input)}`); + if (usage.output) parts.push(`↓${formatTokens(usage.output)}`); + if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`); + if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`); + if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`); + if (usage.contextTokens && usage.contextTokens > 0) { + parts.push(`ctx:${formatTokens(usage.contextTokens)}`); + } + if (model) parts.push(model); + return parts.join(" "); +} + +function formatToolCall( + toolName: string, + args: Record, + themeFg: (color: any, text: string) => string, +): string { + const shortenPath = (p: string) => { + const home = os.homedir(); + return p.startsWith(home) ? `~${p.slice(home.length)}` : p; + }; + + switch (toolName) { + case "bash": { + const command = (args.command as string) || "..."; + const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command; + return themeFg("muted", "$ ") + themeFg("toolOutput", preview); + } + case "read": { + const rawPath = (args.file_path || args.path || "...") as string; + const filePath = shortenPath(rawPath); + const offset = args.offset as number | undefined; + const limit = args.limit as number | undefined; + let text = themeFg("accent", filePath); + if (offset !== undefined || limit !== undefined) { + const startLine = offset ?? 1; + const endLine = limit !== undefined ? startLine + limit - 1 : ""; + text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); + } + return themeFg("muted", "read ") + text; + } + case "write": { + const rawPath = (args.file_path || args.path || "...") as string; + const filePath = shortenPath(rawPath); + const content = (args.content || "") as string; + const lines = content.split("\n").length; + let text = themeFg("muted", "write ") + themeFg("accent", filePath); + if (lines > 1) text += themeFg("dim", ` (${lines} lines)`); + return text; + } + case "edit": { + const rawPath = (args.file_path || args.path || "...") as string; + return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath)); + } + case "ls": { + const rawPath = (args.path || ".") as string; + return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath)); + } + case "find": { + const pattern = (args.pattern || "*") as string; + const rawPath = (args.path || ".") as string; + return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`); + } + case "grep": { + const pattern = (args.pattern || "") as string; + const rawPath = (args.path || ".") as string; + return ( + themeFg("muted", "grep ") + + themeFg("accent", `/${pattern}/`) + + themeFg("dim", ` in ${shortenPath(rawPath)}`) + ); + } + default: { + const argsStr = JSON.stringify(args); + const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr; + return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`); + } + } +} + +interface UsageStats { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + contextTokens: number; + turns: number; +} + +interface SingleResult { + agent: string; + agentSource: "user" | "project" | "unknown"; + task: string; + exitCode: number; + messages: Message[]; + stderr: string; + usage: UsageStats; + model?: string; + stopReason?: string; + errorMessage?: string; + step?: number; +} + +interface SubagentDetails { + mode: "single" | "parallel" | "chain"; + agentScope: AgentScope; + projectAgentsDir: string | null; + results: SingleResult[]; +} + +function getFinalOutput(messages: Message[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") { + for (const part of msg.content) { + if (part.type === "text") return part.text; + } + } + } + return ""; +} + +type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record }; + +function getDisplayItems(messages: Message[]): DisplayItem[] { + const items: DisplayItem[] = []; + for (const msg of messages) { + if (msg.role === "assistant") { + for (const part of msg.content) { + if (part.type === "text") items.push({ type: "text", text: part.text }); + else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments }); + } + } + } + return items; +} + +async function mapWithConcurrencyLimit( + items: TIn[], + concurrency: number, + fn: (item: TIn, index: number) => Promise, +): Promise { + if (items.length === 0) return []; + const limit = Math.max(1, Math.min(concurrency, items.length)); + const results: TOut[] = new Array(items.length); + let nextIndex = 0; + const workers = new Array(limit).fill(null).map(async () => { + while (true) { + const current = nextIndex++; + if (current >= items.length) return; + results[current] = await fn(items[current], current); + } + }); + await Promise.all(workers); + return results; +} + +function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-")); + const safeName = agentName.replace(/[^\w.-]+/g, "_"); + const filePath = path.join(tmpDir, `prompt-${safeName}.md`); + fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 }); + return { dir: tmpDir, filePath }; +} + +type OnUpdateCallback = (partial: AgentToolResult) => void; + +async function runSingleAgent( + defaultCwd: string, + agents: AgentConfig[], + agentName: string, + task: string, + cwd: string | undefined, + step: number | undefined, + signal: AbortSignal | undefined, + onUpdate: OnUpdateCallback | undefined, + makeDetails: (results: SingleResult[]) => SubagentDetails, +): Promise { + const agent = agents.find((a) => a.name === agentName); + + if (!agent) { + return { + agent: agentName, + agentSource: "unknown", + task, + exitCode: 1, + messages: [], + stderr: `Unknown agent: ${agentName}`, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + step, + }; + } + + const args: string[] = ["--mode", "json", "-p", "--no-session"]; + if (agent.model) args.push("--model", agent.model); + if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); + + let tmpPromptDir: string | null = null; + let tmpPromptPath: string | null = null; + + const currentResult: SingleResult = { + agent: agentName, + agentSource: agent.source, + task, + exitCode: 0, + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + model: agent.model, + step, + }; + + const emitUpdate = () => { + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }], + details: makeDetails([currentResult]), + }); + } + }; + + try { + if (agent.systemPrompt.trim()) { + const tmp = writePromptToTempFile(agent.name, agent.systemPrompt); + tmpPromptDir = tmp.dir; + tmpPromptPath = tmp.filePath; + args.push("--append-system-prompt", tmpPromptPath); + } + + args.push(`Task: ${task}`); + let wasAborted = false; + + const exitCode = await new Promise((resolve) => { + const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] }); + let buffer = ""; + + const processLine = (line: string) => { + if (!line.trim()) return; + let event: any; + try { + event = JSON.parse(line); + } catch { + return; + } + + if (event.type === "message_end" && event.message) { + const msg = event.message as Message; + currentResult.messages.push(msg); + + if (msg.role === "assistant") { + currentResult.usage.turns++; + const usage = msg.usage; + if (usage) { + currentResult.usage.input += usage.input || 0; + currentResult.usage.output += usage.output || 0; + currentResult.usage.cacheRead += usage.cacheRead || 0; + currentResult.usage.cacheWrite += usage.cacheWrite || 0; + currentResult.usage.cost += usage.cost?.total || 0; + currentResult.usage.contextTokens = usage.totalTokens || 0; + } + if (!currentResult.model && msg.model) currentResult.model = msg.model; + if (msg.stopReason) currentResult.stopReason = msg.stopReason; + if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage; + } + emitUpdate(); + } + + if (event.type === "tool_result_end" && event.message) { + currentResult.messages.push(event.message as Message); + emitUpdate(); + } + }; + + proc.stdout.on("data", (data) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) processLine(line); + }); + + proc.stderr.on("data", (data) => { + currentResult.stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (buffer.trim()) processLine(buffer); + resolve(code ?? 0); + }); + + proc.on("error", () => { + resolve(1); + }); + + if (signal) { + const killProc = () => { + wasAborted = true; + proc.kill("SIGTERM"); + setTimeout(() => { + if (!proc.killed) proc.kill("SIGKILL"); + }, 5000); + }; + if (signal.aborted) killProc(); + else signal.addEventListener("abort", killProc, { once: true }); + } + }); + + currentResult.exitCode = exitCode; + if (wasAborted) throw new Error("Subagent was aborted"); + return currentResult; + } finally { + if (tmpPromptPath) + try { + fs.unlinkSync(tmpPromptPath); + } catch { + /* ignore */ + } + if (tmpPromptDir) + try { + fs.rmdirSync(tmpPromptDir); + } catch { + /* ignore */ + } + } +} + +const TaskItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task to delegate to the agent" }), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), +}); + +const ChainItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), +}); + +const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, { + description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.', + default: "user", +}); + +const SubagentParams = Type.Object({ + agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })), + task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })), + tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })), + chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })), + agentScope: Type.Optional(AgentScopeSchema), + confirmProjectAgents: Type.Optional( + Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }), + ), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })), +}); + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "subagent", + label: "Subagent", + description: [ + "Delegate tasks to specialized subagents with isolated context.", + "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", + 'Default agent scope is "user" (from ~/.pi/agent/agents).', + 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").', + ].join(" "), + parameters: SubagentParams, + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const agentScope: AgentScope = params.agentScope ?? "user"; + const discovery = discoverAgents(ctx.cwd, agentScope); + const agents = discovery.agents; + const confirmProjectAgents = params.confirmProjectAgents ?? true; + + const hasChain = (params.chain?.length ?? 0) > 0; + const hasTasks = (params.tasks?.length ?? 0) > 0; + const hasSingle = Boolean(params.agent && params.task); + const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle); + + const makeDetails = + (mode: "single" | "parallel" | "chain") => + (results: SingleResult[]): SubagentDetails => ({ + mode, + agentScope, + projectAgentsDir: discovery.projectAgentsDir, + results, + }); + + if (modeCount !== 1) { + const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; + return { + content: [ + { + type: "text", + text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`, + }, + ], + details: makeDetails("single")([]), + }; + } + + if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) { + const requestedAgentNames = new Set(); + if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent); + if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent); + if (params.agent) requestedAgentNames.add(params.agent); + + const projectAgentsRequested = Array.from(requestedAgentNames) + .map((name) => agents.find((a) => a.name === name)) + .filter((a): a is AgentConfig => a?.source === "project"); + + if (projectAgentsRequested.length > 0) { + const names = projectAgentsRequested.map((a) => a.name).join(", "); + const dir = discovery.projectAgentsDir ?? "(unknown)"; + const ok = await ctx.ui.confirm( + "Run project-local agents?", + `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`, + ); + if (!ok) + return { + content: [{ type: "text", text: "Canceled: project-local agents not approved." }], + details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]), + }; + } + } + + if (params.chain && params.chain.length > 0) { + const results: SingleResult[] = []; + let previousOutput = ""; + + for (let i = 0; i < params.chain.length; i++) { + const step = params.chain[i]; + const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput); + + // Create update callback that includes all previous results + const chainUpdate: OnUpdateCallback | undefined = onUpdate + ? (partial) => { + // Combine completed results with current streaming result + const currentResult = partial.details?.results[0]; + if (currentResult) { + const allResults = [...results, currentResult]; + onUpdate({ + content: partial.content, + details: makeDetails("chain")(allResults), + }); + } + } + : undefined; + + const result = await runSingleAgent( + ctx.cwd, + agents, + step.agent, + taskWithContext, + step.cwd, + i + 1, + signal, + chainUpdate, + makeDetails("chain"), + ); + results.push(result); + + const isError = + result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; + if (isError) { + const errorMsg = + result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + return { + content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }], + details: makeDetails("chain")(results), + isError: true, + }; + } + previousOutput = getFinalOutput(result.messages); + } + return { + content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }], + details: makeDetails("chain")(results), + }; + } + + if (params.tasks && params.tasks.length > 0) { + if (params.tasks.length > MAX_PARALLEL_TASKS) + return { + content: [ + { + type: "text", + text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, + }, + ], + details: makeDetails("parallel")([]), + }; + + // Track all results for streaming updates + const allResults: SingleResult[] = new Array(params.tasks.length); + + // Initialize placeholder results + for (let i = 0; i < params.tasks.length; i++) { + allResults[i] = { + agent: params.tasks[i].agent, + agentSource: "unknown", + task: params.tasks[i].task, + exitCode: -1, // -1 = still running + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + }; + } + + const emitParallelUpdate = () => { + if (onUpdate) { + const running = allResults.filter((r) => r.exitCode === -1).length; + const done = allResults.filter((r) => r.exitCode !== -1).length; + onUpdate({ + content: [ + { type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }, + ], + details: makeDetails("parallel")([...allResults]), + }); + } + }; + + const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { + const result = await runSingleAgent( + ctx.cwd, + agents, + t.agent, + t.task, + t.cwd, + undefined, + signal, + // Per-task update callback + (partial) => { + if (partial.details?.results[0]) { + allResults[index] = partial.details.results[0]; + emitParallelUpdate(); + } + }, + makeDetails("parallel"), + ); + allResults[index] = result; + emitParallelUpdate(); + return result; + }); + + const successCount = results.filter((r) => r.exitCode === 0).length; + const summaries = results.map((r) => { + const output = getFinalOutput(r.messages); + const preview = output.slice(0, 100) + (output.length > 100 ? "..." : ""); + return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`; + }); + return { + content: [ + { + type: "text", + text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`, + }, + ], + details: makeDetails("parallel")(results), + }; + } + + if (params.agent && params.task) { + const result = await runSingleAgent( + ctx.cwd, + agents, + params.agent, + params.task, + params.cwd, + undefined, + signal, + onUpdate, + makeDetails("single"), + ); + const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; + if (isError) { + const errorMsg = + result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + return { + content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }], + details: makeDetails("single")([result]), + isError: true, + }; + } + return { + content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }], + details: makeDetails("single")([result]), + }; + } + + const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"; + return { + content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }], + details: makeDetails("single")([]), + }; + }, + + renderCall(args, theme) { + const scope: AgentScope = args.agentScope ?? "user"; + if (args.chain && args.chain.length > 0) { + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", `chain (${args.chain.length} steps)`) + + theme.fg("muted", ` [${scope}]`); + for (let i = 0; i < Math.min(args.chain.length, 3); i++) { + const step = args.chain[i]; + // Clean up {previous} placeholder for display + const cleanTask = step.task.replace(/\{previous\}/g, "").trim(); + const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask; + text += + "\n " + + theme.fg("muted", `${i + 1}.`) + + " " + + theme.fg("accent", step.agent) + + theme.fg("dim", ` ${preview}`); + } + if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`; + return new Text(text, 0, 0); + } + if (args.tasks && args.tasks.length > 0) { + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", `parallel (${args.tasks.length} tasks)`) + + theme.fg("muted", ` [${scope}]`); + for (const t of args.tasks.slice(0, 3)) { + const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task; + text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`; + } + if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`; + return new Text(text, 0, 0); + } + const agentName = args.agent || "..."; + const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "..."; + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", agentName) + + theme.fg("muted", ` [${scope}]`); + text += `\n ${theme.fg("dim", preview)}`; + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as SubagentDetails | undefined; + if (!details || details.results.length === 0) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); + } + + const mdTheme = getMarkdownTheme(); + + const renderDisplayItems = (items: DisplayItem[], limit?: number) => { + const toShow = limit ? items.slice(-limit) : items; + const skipped = limit && items.length > limit ? items.length - limit : 0; + let text = ""; + if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`); + for (const item of toShow) { + if (item.type === "text") { + const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n"); + text += `${theme.fg("toolOutput", preview)}\n`; + } else { + text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`; + } + } + return text.trimEnd(); + }; + + if (details.mode === "single" && details.results.length === 1) { + const r = details.results[0]; + const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted"; + const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); + const displayItems = getDisplayItems(r.messages); + const finalOutput = getFinalOutput(r.messages); + + if (expanded) { + const container = new Container(); + let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`; + if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`; + container.addChild(new Text(header, 0, 0)); + if (isError && r.errorMessage) + container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0)); + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0)); + container.addChild(new Text(theme.fg("dim", r.task), 0, 0)); + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0)); + if (displayItems.length === 0 && !finalOutput) { + container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0)); + } else { + for (const item of displayItems) { + if (item.type === "toolCall") + container.addChild( + new Text( + theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), + 0, + 0, + ), + ); + } + if (finalOutput) { + container.addChild(new Spacer(1)); + container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); + } + } + const usageStr = formatUsageStats(r.usage, r.model); + if (usageStr) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", usageStr), 0, 0)); + } + return container; + } + + let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`; + if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`; + if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`; + else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`; + else { + text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`; + if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; + } + const usageStr = formatUsageStats(r.usage, r.model); + if (usageStr) text += `\n${theme.fg("dim", usageStr)}`; + return new Text(text, 0, 0); + } + + const aggregateUsage = (results: SingleResult[]) => { + const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }; + for (const r of results) { + total.input += r.usage.input; + total.output += r.usage.output; + total.cacheRead += r.usage.cacheRead; + total.cacheWrite += r.usage.cacheWrite; + total.cost += r.usage.cost; + total.turns += r.usage.turns; + } + return total; + }; + + if (details.mode === "chain") { + const successCount = details.results.filter((r) => r.exitCode === 0).length; + const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗"); + + if (expanded) { + const container = new Container(); + container.addChild( + new Text( + icon + + " " + + theme.fg("toolTitle", theme.bold("chain ")) + + theme.fg("accent", `${successCount}/${details.results.length} steps`), + 0, + 0, + ), + ); + + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(r.messages); + const finalOutput = getFinalOutput(r.messages); + + container.addChild(new Spacer(1)); + container.addChild( + new Text( + `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`, + 0, + 0, + ), + ); + container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0)); + + // Show tool calls + for (const item of displayItems) { + if (item.type === "toolCall") { + container.addChild( + new Text( + theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), + 0, + 0, + ), + ); + } + } + + // Show final output as markdown + if (finalOutput) { + container.addChild(new Spacer(1)); + container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); + } + + const stepUsage = formatUsageStats(r.usage, r.model); + if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0)); + } + + const usageStr = formatUsageStats(aggregateUsage(details.results)); + if (usageStr) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0)); + } + return container; + } + + // Collapsed view + let text = + icon + + " " + + theme.fg("toolTitle", theme.bold("chain ")) + + theme.fg("accent", `${successCount}/${details.results.length} steps`); + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(r.messages); + text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`; + if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`; + else text += `\n${renderDisplayItems(displayItems, 5)}`; + } + const usageStr = formatUsageStats(aggregateUsage(details.results)); + if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`; + text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; + return new Text(text, 0, 0); + } + + if (details.mode === "parallel") { + const running = details.results.filter((r) => r.exitCode === -1).length; + const successCount = details.results.filter((r) => r.exitCode === 0).length; + const failCount = details.results.filter((r) => r.exitCode > 0).length; + const isRunning = running > 0; + const icon = isRunning + ? theme.fg("warning", "⏳") + : failCount > 0 + ? theme.fg("warning", "◐") + : theme.fg("success", "✓"); + const status = isRunning + ? `${successCount + failCount}/${details.results.length} done, ${running} running` + : `${successCount}/${details.results.length} tasks`; + + if (expanded && !isRunning) { + const container = new Container(); + container.addChild( + new Text( + `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`, + 0, + 0, + ), + ); + + for (const r of details.results) { + const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(r.messages); + const finalOutput = getFinalOutput(r.messages); + + container.addChild(new Spacer(1)); + container.addChild( + new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0), + ); + container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0)); + + // Show tool calls + for (const item of displayItems) { + if (item.type === "toolCall") { + container.addChild( + new Text( + theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)), + 0, + 0, + ), + ); + } + } + + // Show final output as markdown + if (finalOutput) { + container.addChild(new Spacer(1)); + container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme)); + } + + const taskUsage = formatUsageStats(r.usage, r.model); + if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0)); + } + + const usageStr = formatUsageStats(aggregateUsage(details.results)); + if (usageStr) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0)); + } + return container; + } + + // Collapsed view (or still running) + let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`; + for (const r of details.results) { + const rIcon = + r.exitCode === -1 + ? theme.fg("warning", "⏳") + : r.exitCode === 0 + ? theme.fg("success", "✓") + : theme.fg("error", "✗"); + const displayItems = getDisplayItems(r.messages); + text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`; + if (displayItems.length === 0) + text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`; + else text += `\n${renderDisplayItems(displayItems, 5)}`; + } + if (!isRunning) { + const usageStr = formatUsageStats(aggregateUsage(details.results)); + if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`; + } + if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; + return new Text(text, 0, 0); + } + + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); + }, + }); +} diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/implement-and-review.md b/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/implement-and-review.md new file mode 100644 index 00000000..6493b3d6 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/implement-and-review.md @@ -0,0 +1,10 @@ +--- +description: Worker implements, reviewer reviews, worker applies feedback +--- +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "worker" agent to implement: $@ +2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder) +3. Finally, use the "worker" agent to apply the feedback from the review (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/implement.md b/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/implement.md new file mode 100644 index 00000000..559da4d6 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/implement.md @@ -0,0 +1,10 @@ +--- +description: Full implementation workflow - scout gathers context, planner creates plan, worker implements +--- +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "scout" agent to find all code relevant to: $@ +2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder) +3. Finally, use the "worker" agent to implement the plan from the previous step (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. diff --git a/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/scout-and-plan.md b/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/scout-and-plan.md new file mode 100644 index 00000000..093b6339 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/subagent/prompts/scout-and-plan.md @@ -0,0 +1,9 @@ +--- +description: Scout gathers context, planner creates implementation plan (no implementation) +--- +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "scout" agent to find all code relevant to: $@ +2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan. diff --git a/packages/pi-mnote/extensions/pi-rust-official/todo.ts b/packages/pi-mnote/extensions/pi-rust-official/todo.ts new file mode 100644 index 00000000..0f41abb8 --- /dev/null +++ b/packages/pi-mnote/extensions/pi-rust-official/todo.ts @@ -0,0 +1,299 @@ +/** + * Todo Extension - Demonstrates state management via session entries + * + * This extension: + * - Registers a `todo` tool for the LLM to manage todos + * - Registers a `/todos` command for users to view the list + * + * State is stored in tool result details (not external files), which allows + * proper branching - when you branch, the todo state is automatically + * correct for that point in history. + */ + +import { StringEnum } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; +import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; + +interface Todo { + id: number; + text: string; + done: boolean; +} + +interface TodoDetails { + action: "list" | "add" | "toggle" | "clear"; + todos: Todo[]; + nextId: number; + error?: string; +} + +const TodoParams = Type.Object({ + action: StringEnum(["list", "add", "toggle", "clear"] as const), + text: Type.Optional(Type.String({ description: "Todo text (for add)" })), + id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })), +}); + +/** + * UI component for the /todos command + */ +class TodoListComponent { + private todos: Todo[]; + private theme: Theme; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(todos: Todo[], theme: Theme, onClose: () => void) { + this.todos = todos; + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.onClose(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const lines: string[] = []; + const th = this.theme; + + lines.push(""); + const title = th.fg("accent", " Todos "); + const headerLine = + th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10))); + lines.push(truncateToWidth(headerLine, width)); + lines.push(""); + + if (this.todos.length === 0) { + lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width)); + } else { + const done = this.todos.filter((t) => t.done).length; + const total = this.todos.length; + lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width)); + lines.push(""); + + for (const todo of this.todos) { + const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○"); + const id = th.fg("accent", `#${todo.id}`); + const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text); + lines.push(truncateToWidth(` ${check} ${id} ${text}`, width)); + } + } + + lines.push(""); + lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width)); + lines.push(""); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} + +export default function (pi: ExtensionAPI) { + // In-memory state (reconstructed from session on load) + let todos: Todo[] = []; + let nextId = 1; + + /** + * Reconstruct state from session entries. + * Scans tool results for this tool and applies them in order. + */ + const reconstructState = (ctx: ExtensionContext) => { + todos = []; + nextId = 1; + + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (msg.role !== "toolResult" || msg.toolName !== "todo") continue; + + const details = msg.details as TodoDetails | undefined; + if (details) { + todos = details.todos; + nextId = details.nextId; + } + } + }; + + // Reconstruct state on session events + pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_fork", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + // Register the todo tool for the LLM + pi.registerTool({ + name: "todo", + label: "Todo", + description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", + parameters: TodoParams, + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + switch (params.action) { + case "list": + return { + content: [ + { + type: "text", + text: todos.length + ? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n") + : "No todos", + }, + ], + details: { action: "list", todos: [...todos], nextId } as TodoDetails, + }; + + case "add": { + if (!params.text) { + return { + content: [{ type: "text", text: "Error: text required for add" }], + details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails, + }; + } + const newTodo: Todo = { id: nextId++, text: params.text, done: false }; + todos.push(newTodo); + return { + content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }], + details: { action: "add", todos: [...todos], nextId } as TodoDetails, + }; + } + + case "toggle": { + if (params.id === undefined) { + return { + content: [{ type: "text", text: "Error: id required for toggle" }], + details: { action: "toggle", todos: [...todos], nextId, error: "id required" } as TodoDetails, + }; + } + const todo = todos.find((t) => t.id === params.id); + if (!todo) { + return { + content: [{ type: "text", text: `Todo #${params.id} not found` }], + details: { + action: "toggle", + todos: [...todos], + nextId, + error: `#${params.id} not found`, + } as TodoDetails, + }; + } + todo.done = !todo.done; + return { + content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }], + details: { action: "toggle", todos: [...todos], nextId } as TodoDetails, + }; + } + + case "clear": { + const count = todos.length; + todos = []; + nextId = 1; + return { + content: [{ type: "text", text: `Cleared ${count} todos` }], + details: { action: "clear", todos: [], nextId: 1 } as TodoDetails, + }; + } + + default: + return { + content: [{ type: "text", text: `Unknown action: ${params.action}` }], + details: { + action: "list", + todos: [...todos], + nextId, + error: `unknown action: ${params.action}`, + } as TodoDetails, + }; + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action); + if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`; + if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`; + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as TodoDetails | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (details.error) { + return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + } + + const todoList = details.todos; + + switch (details.action) { + case "list": { + if (todoList.length === 0) { + return new Text(theme.fg("dim", "No todos"), 0, 0); + } + let listText = theme.fg("muted", `${todoList.length} todo(s):`); + const display = expanded ? todoList : todoList.slice(0, 5); + for (const t of display) { + const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○"); + const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text); + listText += `\n${check} ${theme.fg("accent", `#${t.id}`)} ${itemText}`; + } + if (!expanded && todoList.length > 5) { + listText += `\n${theme.fg("dim", `... ${todoList.length - 5} more`)}`; + } + return new Text(listText, 0, 0); + } + + case "add": { + const added = todoList[todoList.length - 1]; + return new Text( + theme.fg("success", "✓ Added ") + + theme.fg("accent", `#${added.id}`) + + " " + + theme.fg("muted", added.text), + 0, + 0, + ); + } + + case "toggle": { + const text = result.content[0]; + const msg = text?.type === "text" ? text.text : ""; + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); + } + + case "clear": + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0); + } + }, + }); + + // Register the /todos command for users + pi.registerCommand("todos", { + description: "Show all todos on the current branch", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("/todos requires interactive mode", "error"); + return; + } + + await ctx.ui.custom((_tui, theme, _kb, done) => { + return new TodoListComponent(todos, theme, () => done()); + }); + }, + }); +} diff --git a/packages/pi-mnote/package.json b/packages/pi-mnote/package.json new file mode 100644 index 00000000..4ee36b5d --- /dev/null +++ b/packages/pi-mnote/package.json @@ -0,0 +1,25 @@ +{ + "name": "@mnote/pi", + "version": "0.1.0", + "private": true, + "description": "MNote official Pi package for local-first page tools, LightRAG, citations, and rescue workflows.", + "keywords": [ + "pi-package", + "mnote", + "lightrag", + "knowledge-rag" + ], + "license": "UNLICENSED", + "pi": { + "extensions": [ + "./extensions/mnote-bridge.ts", + "./extensions/mnote-mcp/index.ts" + ], + "skills": [ + "./skills" + ], + "prompts": [ + "./prompts" + ] + } +} diff --git a/packages/pi-mnote/prompts/mnote-rag.md b/packages/pi-mnote/prompts/mnote-rag.md new file mode 100644 index 00000000..f0ec8af6 --- /dev/null +++ b/packages/pi-mnote/prompts/mnote-rag.md @@ -0,0 +1,3 @@ +# MNote LightRAG Answer + +Answer with MNote LightRAG evidence. Query the MNote knowledge library first, use returned citations, and open or expand references only when needed. diff --git a/packages/pi-mnote/skills/mnote/SKILL.md b/packages/pi-mnote/skills/mnote/SKILL.md new file mode 100644 index 00000000..77e36af1 --- /dev/null +++ b/packages/pi-mnote/skills/mnote/SKILL.md @@ -0,0 +1,12 @@ +--- +name: mnote +description: Use MNote page, local file, citation, and LightRAG tools from Pi while respecting MNote access policy. +--- + +# MNote + +Use MNote tools when the task depends on the current MNote page, selection, allowed local roots, MNote citations, or the configured MNote knowledge library. + +Prefer `mnote_current_page_read` for current-page context and `mnote_knowledge_rag_query` for source-grounded knowledge answers. For long books or documents, use `mnote_knowledge_rag_section_context` after a query returns a relevant section or document structure index. + +Do not invent MNote citation URLs. Use returned citations or `mnote_knowledge_rag_open_reference`. diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js index 5f061bca..a0f1b2d4 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js @@ -1,8 +1,8 @@ // == Pi Lab Page AI Runtime == // MNote-native adapter for Pi-first Page AI Lab. -// Renders an independent floating launcher and drawer. OpenHub remains the -// default Page AI surface; Pi Lab never mounts inside the OpenHub drawer, iframe -// page, provider tab, or page container. NO setInterval polling. +// Renders the MNote-owned Pi Rust Page AI surface. OpenHub is kept as a +// compatibility/admin boundary during migration, not as the default chat owner. +// NO setInterval polling. // // UI note: @earendil-works/pi-web-ui@0.75.3 was checked as the mature upstream UI. // It expects browser-side pi-agent-core, IndexedDB storage, API-key dialogs, model @@ -28,6 +28,7 @@ START: '/api/page-ai/pi/start', SEND: '/api/page-ai/pi/send', ABORT: '/api/page-ai/pi/abort', + UI_RESPONSE: '/api/page-ai/pi/ui-response', EVENTS: '/api/page-ai/pi/events', BOOTSTRAP: '/api/page-ai/pi/bootstrap', SESSIONS: '/api/page-ai/pi/sessions', @@ -36,12 +37,15 @@ var DEFAULT_MODEL_PROVIDER = 'omniroute'; var DEFAULT_MODEL_ID = 'freefirst'; + var DEFAULT_THINKING_LEVEL = 'medium'; + var DEFAULT_PERMISSION_MODE = 'confirm'; var piLabInstalled = false; var piLabPanelEl = null; var piLabDrawerEl = null; var piLabLauncherEl = null; var piLabEventSource = null; + var piLabStartPromise = null; var piLabState = { status: STATE_IDLE, enabled: false, @@ -51,11 +55,25 @@ providerSessionId: null, defaultModelProvider: DEFAULT_MODEL_PROVIDER, defaultModelId: DEFAULT_MODEL_ID, + defaultThinkingLevel: DEFAULT_THINKING_LEVEL, modelProvider: null, modelId: null, + thinkingLevel: DEFAULT_THINKING_LEVEL, + permissionMode: DEFAULT_PERMISSION_MODE, + permissionMenuOpen: false, + pendingPermissionModeApply: false, + applyingPermissionMode: false, + actionMenuOpen: false, runtimeMode: null, + runtimeImplementation: null, + runtimeBinary: null, + runtimeAvailable: true, + runtimeInstallHint: '', + runtimeError: '', runtimePid: null, - disabledBuiltinTools: null, + managedBuiltinTools: [], + piExtensionSources: [], + piExtensionToolNames: [], session: null, allowedRootsSummary: '', selectionSummary: '', @@ -66,8 +84,243 @@ receipts: [], modelOptions: [], history: [], + pendingQueue: { steering: [], followUp: [] }, + viewingHistorySessionId: null, + contextSelection: { + currentPage: false, + currentFolder: false, + selection: false, + lightrag: false, + }, }; var streamingAssistantMsg = null; + var piRunViewModel = createPiRunViewModel(); + var activePiUiDialog = null; + var piCustomUiState = { + panel: null, + widgetKey: '', + title: '', + lines: [], + selectedOptionIndex: 0, + keyQueue: [], + submitted: false, + pendingCustomRequest: null, + }; + var piToastSeq = 0; + + function createPiRunViewModel(seed) { + seed = seed || {}; + return { + turns: Array.isArray(seed.turns) ? seed.turns : [], + messages: Array.isArray(seed.messages) ? seed.messages : [], + toolCallsById: seed.toolCallsById || {}, + pendingQueue: seed.pendingQueue || { steering: [], followUp: [] }, + currentAssistant: seed.currentAssistant || null, + runtimeStatus: seed.runtimeStatus || STATE_IDLE, + abortResponse: seed.abortResponse || null, + lastEventType: seed.lastEventType || '', + lastToolCall: null, + }; + } + + function normalizeQueueItems(value) { + if (!Array.isArray(value)) return []; + return value.map(function (item) { + if (item == null) return ''; + if (typeof item === 'string') return item; + return { + id: item.id || item.messageId || item.queueId || '', + text: item.text || item.message || item.prompt || '', + createdAt: item.createdAt || item.timestamp || '', + }; + }).filter(function (item) { + return typeof item === 'string' ? item.trim() : (item.text || item.id); + }); + } + + function rebuildPiRunToolIndex(vm) { + vm.toolCallsById = {}; + (vm.messages || []).forEach(function (msg) { + (msg && Array.isArray(msg.toolCalls) ? msg.toolCalls : []).forEach(function (tc) { + if (!tc) return; + var key = tc.id || tc.name || ('tool-' + Object.keys(vm.toolCallsById).length); + vm.toolCallsById[key] = tc; + }); + }); + return vm.toolCallsById; + } + + function syncPiRunViewModelFromState() { + piRunViewModel.messages = piLabState.messages; + piRunViewModel.pendingQueue = piLabState.pendingQueue || { steering: [], followUp: [] }; + piRunViewModel.currentAssistant = streamingAssistantMsg; + piRunViewModel.runtimeStatus = piLabState.status; + rebuildPiRunToolIndex(piRunViewModel); + return piRunViewModel; + } + + function syncPiRunViewModelToState() { + piLabState.messages = piRunViewModel.messages; + piLabState.pendingQueue = piRunViewModel.pendingQueue || { steering: [], followUp: [] }; + streamingAssistantMsg = piRunViewModel.currentAssistant || null; + return piRunViewModel; + } + + function resetPiRunViewModel(options) { + piRunViewModel = createPiRunViewModel({ runtimeStatus: piLabState.status }); + if (!options || options.sync !== false) syncPiRunViewModelToState(); + return piRunViewModel; + } + + function enterHistoryReplayMode(sessionId) { + piLabState.viewingHistorySessionId = sessionId || piLabState.sessionId || null; + if (piLabEventSource) { + piLabEventSource.close(); + piLabEventSource = null; + } + } + + function exitHistoryReplayMode() { + piLabState.viewingHistorySessionId = null; + } + + function ensurePiRunAssistant(vm) { + if (!vm.currentAssistant) { + vm.currentAssistant = { role: 'assistant', text: '', toolCalls: [], citations: [], diffSummary: null, status: 'streaming' }; + vm.messages.push(vm.currentAssistant); + } + vm.currentAssistant.status = vm.currentAssistant.status || 'streaming'; + vm.runtimeStatus = STATE_STREAMING; + return vm.currentAssistant; + } + + function findLastAssistant(vm) { + return (vm.messages || []).slice().reverse().find(function (message) { + return message && message.role === 'assistant'; + }) || null; + } + + function piRunUpsertToolCall(vm, raw, status, patch) { + var msg = vm.currentAssistant || findLastAssistant(vm) || ensurePiRunAssistant(vm); + var payload = raw || {}; + var calls = ensureToolCalls(msg); + var id = normalizeToolCallId(payload); + var name = toolDisplayName(payload); + var found = null; + if (id) found = calls.find(function (item) { return item && item.id === id; }); + if (!found && name) { + found = calls.slice().reverse().find(function (item) { + return item && item.name === name && (item.status === 'running' || item.status === 'pending'); + }); + } + if (!found) { + found = { id: id || ('tool-' + (calls.length + 1)), name: name, args: {}, status: status || 'running' }; + calls.push(found); + } + found.id = id || found.id; + found.name = name || found.name; + found.status = status || found.status || 'running'; + if (payload.args || payload.input || payload.params) found.args = payload.args || payload.input || payload.params; + if (payload.details) found.details = payload.details; + Object.keys(patch || {}).forEach(function (key) { found[key] = patch[key]; }); + vm.lastToolCall = found; + rebuildPiRunToolIndex(vm); + return found; + } + + function reducePiRunViewModel(vm, event) { + vm = vm || createPiRunViewModel(); + event = event || {}; + var payload = event.payload || {}; + vm.lastEventType = event.type || ''; + if (event.type === 'clear') { + vm.messages = []; + vm.toolCallsById = {}; + vm.pendingQueue = { steering: [], followUp: [] }; + vm.currentAssistant = null; + vm.abortResponse = null; + return vm; + } + if (event.type === 'queue_update') { + vm.pendingQueue = { + steering: normalizeQueueItems(payload.steering), + followUp: normalizeQueueItems(payload.followUp), + }; + return vm; + } + if (event.type === 'user_prompt') { + vm.messages.push({ role: 'user', text: event.text || payload.text || payload.message || '', meta: event.meta || payload.meta || '' }); + return vm; + } + if (event.type === 'assistant_begin') { + ensurePiRunAssistant(vm); + return vm; + } + if (event.type === 'assistant_delta') { + var deltaMsg = ensurePiRunAssistant(vm); + if (event.delta || payload.delta) deltaMsg.text += event.delta || payload.delta; + if ((event.text || payload.text) && !(event.delta || payload.delta)) deltaMsg.text = event.text || payload.text; + return vm; + } + if (event.type === 'assistant_text_end') { + var textEndMsg = ensurePiRunAssistant(vm); + textEndMsg.text = event.text || payload.text || payload.content || textEndMsg.text; + return vm; + } + if (event.type === 'assistant_done') { + var doneMsg = ensurePiRunAssistant(vm); + if (event.text || payload.text) doneMsg.text = event.text || payload.text; + if (event.toolCalls || payload.toolCalls) doneMsg.toolCalls = event.toolCalls || payload.toolCalls; + if (event.citations || payload.citations) doneMsg.citations = event.citations || payload.citations; + if (event.diffSummary || payload.diffSummary) doneMsg.diffSummary = event.diffSummary || payload.diffSummary; + doneMsg.status = 'done'; + vm.currentAssistant = null; + vm.runtimeStatus = STATE_STARTED; + rebuildPiRunToolIndex(vm); + return vm; + } + if (event.type === 'assistant_abort') { + var abortMsg = vm.currentAssistant || findLastAssistant(vm) || ensurePiRunAssistant(vm); + abortMsg.status = 'aborted'; + abortMsg.abortReason = event.reason || payload.reason || payload.stopReason || payload.error || payload.message || '已中止'; + abortMsg.abortResponse = payload || { reason: abortMsg.abortReason }; + vm.abortResponse = payload || { reason: abortMsg.abortReason }; + vm.currentAssistant = null; + vm.runtimeStatus = STATE_ABORTED; + return vm; + } + if (event.type === 'assistant_error') { + var errMsg = ensurePiRunAssistant(vm); + errMsg.text += (errMsg.text ? '\n' : '') + '错误: ' + (event.message || payload.error || payload.message || 'Pi runtime error'); + errMsg.status = 'done'; + vm.currentAssistant = null; + vm.runtimeStatus = STATE_STARTED; + return vm; + } + if (event.type === 'tool_upsert') { + piRunUpsertToolCall(vm, payload.raw || payload, payload.status || event.status, payload.patch || event.patch || {}); + return vm; + } + if (event.type === 'citation') { + var citMsg = ensurePiRunAssistant(vm); + citMsg.citations.push({ source: payload.source || '', title: payload.title || '', url: payload.url || '#' }); + return vm; + } + if (event.type === 'diff') { + var diffMsg = ensurePiRunAssistant(vm); + diffMsg.diffSummary = diffMsg.diffSummary || { files: [] }; + diffMsg.diffSummary.files = payload.files || []; + return vm; + } + return vm; + } + + function applyPiRunEvent(event) { + syncPiRunViewModelFromState(); + reducePiRunViewModel(piRunViewModel, event); + syncPiRunViewModelToState(); + return piRunViewModel; + } function injectStyles() { var styleId = 'pi-lab-runtime-style'; @@ -82,7 +335,7 @@ '.wolai-page-ai-pi-lab-drawer[hidden]{display:none!important}' + '.wolai-page-ai-pi-lab-drawer[data-minimized="true"]{top:auto;height:54px;width:min(420px,calc(100vw - 36px))}' + '.wolai-page-ai-pi-lab-drawer[data-minimized="true"] .wolai-page-ai-pi-lab-config,.wolai-page-ai-pi-lab-drawer[data-minimized="true"] .wolai-page-ai-pi-lab-context-strip,.wolai-page-ai-pi-lab-drawer[data-minimized="true"] .wolai-page-ai-pi-lab-body,.wolai-page-ai-pi-lab-drawer[data-minimized="true"] .wolai-page-ai-pi-lab-diagnostics{display:none!important}' + - '.wolai-page-ai-pi-lab-shell{display:flex;flex-direction:column;height:100%;min-height:0;background:#f5f6fa;color:#242424;font:13px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden}' + + '.wolai-page-ai-pi-lab-shell{position:relative;display:flex;flex-direction:column;height:100%;min-height:0;background:#f5f6fa;color:#242424;font:13px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden}' + '.wolai-page-ai-pi-lab-shell[hidden]{display:none!important}' + '.wolai-page-ai-pi-lab-topbar{height:58px;display:flex;align-items:center;gap:10px;padding:0 18px;background:#f5f6fa;flex:0 0 auto}' + '.wolai-page-ai-pi-lab-brand{display:flex;align-items:center;gap:8px;min-width:0;flex:1}' + @@ -92,12 +345,13 @@ '.wolai-page-ai-pi-lab-title span{font-size:11px;color:#6f6a60;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + '.wolai-page-ai-pi-lab-icon-btn{width:30px;height:30px;border:1px solid #e2ded6;border-radius:8px;background:#fff;color:#514f49;display:grid;place-items:center;cursor:pointer}' + '.wolai-page-ai-pi-lab-icon-btn:hover{background:#f7f6f2}' + - '.wolai-page-ai-pi-lab-commandbar{height:52px;margin:0 14px 28px;padding:0 14px;border:1px solid #e5e8ef;border-radius:14px;background:#fff;display:flex;align-items:center;gap:14px;box-shadow:0 2px 8px rgba(15,23,42,.04);flex:0 0 auto}' + - '.wolai-page-ai-pi-lab-commandbar .wolai-page-ai-pi-lab-tool-icon{width:28px;height:28px;border:0;background:transparent;color:#30343b;display:grid;place-items:center;font-size:19px;cursor:pointer;border-radius:8px}' + - '.wolai-page-ai-pi-lab-commandbar .wolai-page-ai-pi-lab-tool-icon:hover{background:#f3f5f8}' + + '.wolai-page-ai-pi-lab-commandbar{height:50px;margin:0 14px 12px;padding:0 12px;border:1px solid #e4e7ef;border-radius:14px;background:#fff;display:flex;align-items:center;gap:8px;box-shadow:0 2px 8px rgba(15,23,42,.04);flex:0 0 auto}' + + '.wolai-page-ai-pi-lab-commandbar .wolai-page-ai-pi-lab-tool-icon{width:32px;height:32px;border:0;background:transparent;color:#3c424c;display:grid;place-items:center;cursor:pointer;border-radius:9px}' + + '.wolai-page-ai-pi-lab-commandbar .wolai-page-ai-pi-lab-tool-icon svg,.wolai-page-ai-pi-lab-square svg,.wolai-page-ai-pi-lab-icon-btn svg{width:17px;height:17px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}' + + '.wolai-page-ai-pi-lab-commandbar .wolai-page-ai-pi-lab-tool-icon:hover,.wolai-page-ai-pi-lab-commandbar .wolai-page-ai-pi-lab-tool-icon[data-active="true"]{background:#f3f5f8;color:#111827}' + '.wolai-page-ai-pi-lab-commandbar .wolai-page-ai-pi-lab-tool-icon[disabled]{opacity:.35;cursor:default}' + '.wolai-page-ai-pi-lab-commandbar-spacer{flex:1}' + - '.wolai-page-ai-pi-lab-config{display:none;grid-template-columns:minmax(0,1fr) auto;gap:10px;margin:-18px 14px 12px;padding:10px 12px;border:1px solid #e5e8ef;border-radius:12px;background:#fff;box-shadow:0 4px 14px rgba(15,23,42,.05);flex:0 0 auto}' + + '.wolai-page-ai-pi-lab-config{display:none;grid-template-columns:minmax(0,1fr) auto;gap:10px;margin:0 14px 12px;padding:10px 12px;border:1px solid #e5e8ef;border-radius:12px;background:#fff;box-shadow:0 4px 14px rgba(15,23,42,.05);flex:0 0 auto}' + '.wolai-page-ai-pi-lab-drawer[data-config-open="true"] .wolai-page-ai-pi-lab-config{display:grid}' + '.wolai-page-ai-pi-lab-model-row{display:flex;flex-wrap:wrap;gap:6px;min-width:0}' + '.wolai-page-ai-pi-lab-settings{border:0;min-width:0}' + @@ -106,12 +360,36 @@ '.wolai-page-ai-pi-lab-settings>summary::before{content:"▸";font-size:10px;color:#8a8378}' + '.wolai-page-ai-pi-lab-settings[open]>summary::before{content:"▾"}' + '.wolai-page-ai-pi-lab-settings-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:8px;max-width:560px}' + - '.wolai-page-ai-pi-lab-history-panel{margin:0 18px 10px;border:1px solid #e2ded6;border-radius:8px;background:#fff;max-height:220px;overflow:auto}' + - '.wolai-page-ai-pi-lab-history-panel[hidden]{display:none!important}' + - '.wolai-page-ai-pi-lab-history-row{width:100%;border:0;border-bottom:1px solid #f0eee9;background:#fff;padding:10px 12px;text-align:left;cursor:pointer;display:flex;justify-content:space-between;gap:10px}' + - '.wolai-page-ai-pi-lab-history-row:hover{background:#f8f7f4}' + - '.wolai-page-ai-pi-lab-history-row strong{display:block;font-size:12px;color:#2e2c28}' + - '.wolai-page-ai-pi-lab-history-row span{font-size:11px;color:#817b72}' + + '.wolai-page-ai-pi-lab-history-layer{position:absolute;inset:0;z-index:28;display:block;pointer-events:auto}' + + '.wolai-page-ai-pi-lab-history-layer[hidden]{display:none!important}' + + '.wolai-page-ai-pi-lab-history-scrim{position:absolute;inset:0;background:rgba(15,23,42,.22);backdrop-filter:blur(1px);border-radius:18px}' + + '.wolai-page-ai-pi-lab-history-panel{position:absolute;left:0;top:0;bottom:0;width:min(340px,calc(100vw - 34px));border-right:1px solid #dfe4ec;background:#fff;box-shadow:18px 0 42px rgba(15,23,42,.18);display:flex;flex-direction:column;overflow:hidden}' + + '.wolai-page-ai-pi-lab-history-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:48px;padding:0 14px;border-bottom:1px solid #edf0f5;background:#fff;color:#1f2937;font-size:14px;font-weight:750}' + + '.wolai-page-ai-pi-lab-history-close{position:relative;width:26px;height:26px;border:1px solid #e1e4eb;border-radius:8px;background:#fff;color:#4a4f58;display:grid;place-items:center;cursor:pointer;font-size:0;line-height:1}' + + '.wolai-page-ai-pi-lab-history-close::before,.wolai-page-ai-pi-lab-history-close::after{content:"";position:absolute;left:7px;right:7px;top:12px;height:1.5px;background:currentColor;border-radius:999px}' + + '.wolai-page-ai-pi-lab-history-close::before{transform:rotate(45deg)}.wolai-page-ai-pi-lab-history-close::after{transform:rotate(-45deg)}' + + '.wolai-page-ai-pi-lab-history-close svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}' + + '.wolai-page-ai-pi-lab-history-toolbar{display:flex;align-items:center;gap:8px;padding:12px 14px 10px;border-bottom:1px solid #f0f2f6;background:#fafbfc}' + + '.wolai-page-ai-pi-lab-history-new{height:32px;flex:1;border:1px solid #1f6feb;border-radius:8px;background:#1f6feb;color:#fff;font:12px/1 inherit;font-weight:700;cursor:pointer}' + + '.wolai-page-ai-pi-lab-history-refresh,.wolai-page-ai-pi-lab-history-clear{height:32px;border:1px solid #d9dee8;border-radius:8px;background:#fff;color:#4b5563;font:12px/1 inherit;padding:0 9px;cursor:pointer}' + + '.wolai-page-ai-pi-lab-history-clear{color:#b42318;border-color:#f2c4bd}' + + '.wolai-page-ai-pi-lab-history-meta{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 14px;color:#6b7280;font-size:11px;border-bottom:1px solid #f0f2f6}' + + '.wolai-page-ai-pi-lab-history-list{min-height:0;overflow:auto;flex:1}' + + '.wolai-page-ai-pi-lab-history-row{border-bottom:1px solid #f0eee9;background:#fff;padding:11px 12px;display:grid;grid-template-columns:10px minmax(0,1fr) auto;gap:10px;align-items:center}' + + '.wolai-page-ai-pi-lab-history-row:hover{background:#f8faff}' + + '.wolai-page-ai-pi-lab-history-dot{width:7px;height:7px;border-radius:999px;background:#1f6feb;opacity:.35}' + + '.wolai-page-ai-pi-lab-history-row[data-active="true"] .wolai-page-ai-pi-lab-history-dot{opacity:1}' + + '.wolai-page-ai-pi-lab-history-row strong{display:block;font-size:12px;color:#1f2937}' + + '.wolai-page-ai-pi-lab-history-row span{font-size:11px;color:#6b7280}' + + '.wolai-page-ai-pi-lab-history-main{min-width:0;border:0;background:transparent;padding:0;text-align:left;cursor:pointer}' + + '.wolai-page-ai-pi-lab-history-main strong,.wolai-page-ai-pi-lab-history-main span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + + '.wolai-page-ai-pi-lab-history-actions{display:flex;gap:4px;align-items:center}' + + '.wolai-page-ai-pi-lab-history-action{border:1px solid #d8dee8;border-radius:6px;background:#fff;padding:4px 6px;font-size:11px;color:#4b5563;cursor:pointer;white-space:nowrap}' + + '.wolai-page-ai-pi-lab-history-action:hover{background:#f3f6fb}' + + '.wolai-page-ai-pi-lab-history-danger{color:#b42318;border-color:#f2c4bd}' + + '.wolai-page-ai-pi-lab-history-row[data-active="true"]{background:#f4f7ff}.wolai-page-ai-pi-lab-history-row[data-active="true"] .wolai-page-ai-pi-lab-history-main strong{color:#1f3f8f}' + + '.wolai-page-ai-pi-lab-replay-banner{margin:0 18px 10px;padding:8px 10px;border:1px solid #dbe5ff;border-radius:8px;background:#f6f8ff;color:#36507d;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:10px}' + + '.wolai-page-ai-pi-lab-replay-banner[hidden]{display:none!important}.wolai-page-ai-pi-lab-replay-banner button{border:1px solid #cfd9f5;border-radius:7px;background:#fff;color:#24406d;height:26px;padding:0 9px;font:12px/1 inherit;cursor:pointer}' + '.wolai-page-ai-pi-lab-field{display:flex;flex-direction:column;gap:4px;font-size:11px;color:#6f6a60}' + '.wolai-page-ai-pi-lab-field select,.wolai-page-ai-pi-lab-field input{height:30px;border:1px solid #ddd8cf;border-radius:8px;background:#fff;color:#242424;padding:0 8px;font:12px/1.3 inherit;min-width:0}' + '.wolai-page-ai-pi-lab-context-strip{display:none;padding:0;margin:-6px 14px 12px;border:1px solid #ebe7de;border-radius:12px;background:#faf9f6;flex:0 0 auto}' + @@ -137,9 +415,9 @@ '.wolai-page-ai-pi-lab-btn-primary{background:#1f6feb;border-color:#1f6feb;color:#fff}' + '.wolai-page-ai-pi-lab-btn-primary:hover{background:#1b61cf}' + '.wolai-page-ai-pi-lab-btn-danger{background:#d93025;border-color:#d93025;color:#fff}' + - '.wolai-page-ai-pi-lab-body{display:block;min-height:0;flex:1;background:#f5f6fa;padding:0 14px 12px}' + - '.wolai-page-ai-pi-lab-main{display:flex;flex-direction:column;min-width:0;min-height:0;height:100%}' + - '.wolai-page-ai-pi-lab-messages{flex:1;min-height:0;overflow-y:auto;padding:22px 18px 20px;display:flex;flex-direction:column;gap:12px;overscroll-behavior:contain;background:#fff;border:1px solid #e4e7ef;border-radius:22px 22px 0 0;box-shadow:0 12px 36px rgba(15,23,42,.08)}' + + '.wolai-page-ai-pi-lab-body{position:relative;display:block;min-height:0;flex:1;background:#f5f6fa;padding:0 14px 12px}' + + '.wolai-page-ai-pi-lab-main{position:relative;display:flex;flex-direction:column;min-width:0;min-height:0;height:100%;overflow:hidden}' + + '.wolai-page-ai-pi-lab-messages{flex:1;min-height:0;overflow-y:auto;padding:22px 18px 20px;display:flex;flex-direction:column;gap:12px;overscroll-behavior:contain;background:#fff;border:1px solid #e4e7ef;border-radius:16px 16px 0 0;box-shadow:0 10px 28px rgba(15,23,42,.07)}' + '.wolai-page-ai-pi-lab-empty{margin:auto;max-width:460px;text-align:center;color:#8a9099;background:transparent;border:0;border-radius:0;padding:10px 0 4px;box-shadow:none}' + '.wolai-page-ai-pi-lab-empty-icon{width:76px;height:76px;margin:0 auto 18px;border-radius:50%;background:#f0f2f6;color:#8a93a2;display:grid;place-items:center;font-size:42px;font-weight:400}' + '.wolai-page-ai-pi-lab-empty strong{display:block;color:#242833;margin-bottom:8px;font-size:25px;font-weight:760;letter-spacing:0}' + @@ -153,34 +431,99 @@ '.wolai-page-ai-pi-lab-message[data-role="assistant"],.wolai-page-ai-pi-lab-message[data-role="system"]{align-self:stretch}' + '.wolai-page-ai-pi-lab-role{display:flex;align-items:center;gap:6px;color:#777166;font-size:11px;font-weight:650}' + '.wolai-page-ai-pi-lab-message[data-role="user"] .wolai-page-ai-pi-lab-role{justify-content:flex-end}' + - '.wolai-page-ai-pi-lab-bubble{border:1px solid #e6e2da;border-radius:12px;background:#fff;padding:10px 12px;color:#272521;word-break:break-word;box-shadow:0 4px 14px rgba(15,23,42,.035)}' + + '.wolai-page-ai-pi-lab-bubble{position:relative;border:1px solid #e6e2da;border-radius:12px;background:#fff;padding:10px 12px;color:#272521;word-break:break-word;box-shadow:0 4px 14px rgba(15,23,42,.035)}' + '.wolai-page-ai-pi-lab-message[data-role="user"] .wolai-page-ai-pi-lab-bubble{background:#f3f7ff;border-color:#d9e6ff}' + '.wolai-page-ai-pi-lab-text{white-space:pre-wrap}' + - '.wolai-page-ai-pi-lab-text.streaming-cursor::after{content:"";display:inline-block;width:7px;height:14px;margin-left:3px;vertical-align:-2px;background:#1f6feb;animation:pi-blink .85s steps(2,start) infinite}' + + '.wolai-page-ai-pi-lab-markdown{font-size:14px;line-height:1.62;color:#272521}' + + '.wolai-page-ai-pi-lab-markdown>*:first-child{margin-top:0}' + + '.wolai-page-ai-pi-lab-markdown>*:last-child{margin-bottom:0}' + + '.wolai-page-ai-pi-lab-markdown p{margin:0 0 10px}' + + '.wolai-page-ai-pi-lab-markdown h1,.wolai-page-ai-pi-lab-markdown h2,.wolai-page-ai-pi-lab-markdown h3{margin:14px 0 8px;font-weight:720;letter-spacing:0;line-height:1.28;color:#202124}' + + '.wolai-page-ai-pi-lab-markdown h1{font-size:20px}.wolai-page-ai-pi-lab-markdown h2{font-size:17px}.wolai-page-ai-pi-lab-markdown h3{font-size:15px}' + + '.wolai-page-ai-pi-lab-markdown ul,.wolai-page-ai-pi-lab-markdown ol{margin:0 0 10px 20px;padding:0}' + + '.wolai-page-ai-pi-lab-markdown li{margin:3px 0;padding-left:2px}' + + '.wolai-page-ai-pi-lab-markdown blockquote{margin:0 0 10px;padding:2px 0 2px 12px;border-left:3px solid #d8dce6;color:#5f6368}' + + '.wolai-page-ai-pi-lab-markdown pre{margin:8px 0 12px;padding:10px 12px;border:1px solid #e4e7ef;border-radius:8px;background:#f7f8fb;overflow:auto;white-space:pre;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#242833}' + + '.wolai-page-ai-pi-lab-markdown code{border:1px solid #e5e7eb;border-radius:5px;background:#f7f8fb;padding:1px 4px;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#1f2937}' + + '.wolai-page-ai-pi-lab-markdown pre code{border:0;background:transparent;padding:0;font:inherit;color:inherit}' + + '.wolai-page-ai-pi-lab-markdown table{width:100%;border-collapse:collapse;margin:8px 0 12px;font-size:13px}' + + '.wolai-page-ai-pi-lab-markdown th,.wolai-page-ai-pi-lab-markdown td{border:1px solid #e4e7ef;padding:6px 8px;text-align:left;vertical-align:top}' + + '.wolai-page-ai-pi-lab-markdown th{background:#f7f8fb;font-weight:650}' + + '.wolai-page-ai-pi-lab-markdown a{color:#1f6feb;text-decoration:none}.wolai-page-ai-pi-lab-markdown a:hover{text-decoration:underline}' + + '.wolai-page-ai-pi-lab-text.streaming-cursor::after,.wolai-page-ai-pi-lab-markdown.streaming-cursor::after{content:"";display:inline-block;width:7px;height:14px;margin-left:3px;vertical-align:-2px;background:#1f6feb;animation:pi-blink .85s steps(2,start) infinite}' + '@keyframes pi-blink{50%{opacity:.18}}' + '.wolai-page-ai-pi-lab-tool-calls,.wolai-page-ai-pi-lab-citations{display:flex;flex-direction:column;gap:6px;margin-top:8px}' + - '.wolai-page-ai-pi-lab-tool-call{border:1px solid #e5e0d8;border-radius:9px;background:#faf9f6;overflow:hidden}' + - '.wolai-page-ai-pi-lab-tool-call-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;font-size:12px;font-weight:650;color:#42403a}' + - '.wolai-page-ai-pi-lab-tool-call-header span{font-size:11px;color:#777166;font-weight:500}' + - '.wolai-page-ai-pi-lab-tool-call pre{margin:0;border-top:1px solid #ebe7de;background:#fff;padding:8px;max-height:140px;overflow:auto;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;color:#42403a;white-space:pre-wrap}' + + '.wolai-page-ai-pi-lab-tool-timeline{margin-top:10px;border:1px solid #e4e7ef;border-radius:8px;background:#fafbfc;overflow:hidden}' + + '.wolai-page-ai-pi-lab-tool-timeline>summary{min-height:32px;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 10px;cursor:pointer;list-style:none;color:#41464f;font-size:12px;font-weight:650}' + + '.wolai-page-ai-pi-lab-tool-timeline>summary::-webkit-details-marker{display:none}' + + '.wolai-page-ai-pi-lab-tool-timeline>summary::after{content:"▸";font-size:10px;color:#7c8593}' + + '.wolai-page-ai-pi-lab-tool-timeline[open]>summary::after{content:"▾"}' + + '.wolai-page-ai-pi-lab-tool-timeline-body{display:flex;flex-direction:column;gap:6px;padding:8px;border-top:1px solid #e4e7ef}' + + '.wolai-page-ai-pi-lab-tool-call{border:1px solid #e5e0d8;border-radius:9px;background:#fff;overflow:hidden}' + + '.wolai-page-ai-pi-lab-tool-call[data-status="running"]{border-color:#bcd7ff;background:#f7fbff}' + + '.wolai-page-ai-pi-lab-tool-call[data-status="error"]{border-color:#f2c4bd;background:#fff8f7}' + + '.wolai-page-ai-pi-lab-tool-call-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;font-size:12px;font-weight:650;color:#42403a}' + + '.wolai-page-ai-pi-lab-tool-call-header span{font-size:11px;color:#777166;font-weight:500}' + + '.wolai-page-ai-pi-lab-tool-call-summary-line{padding:0 9px 7px;color:#6f6a60;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + + '.wolai-page-ai-pi-lab-tool-call-details{border-top:1px solid #ebe7de;background:#fff}' + + '.wolai-page-ai-pi-lab-tool-call-details>summary{min-height:28px;display:flex;align-items:center;gap:6px;padding:0 9px;cursor:pointer;list-style:none;color:#5d594f;font-size:11px}' + + '.wolai-page-ai-pi-lab-tool-call-details>summary::-webkit-details-marker{display:none}' + + '.wolai-page-ai-pi-lab-tool-call-details>summary::before{content:"▸";font-size:10px;color:#8a8378}.wolai-page-ai-pi-lab-tool-call-details[open]>summary::before{content:"▾"}' + + '.wolai-page-ai-pi-lab-tool-call pre{margin:0;border-top:1px solid #ebe7de;background:#fff;padding:8px;max-height:140px;overflow:auto;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;color:#42403a;white-space:pre-wrap}' + + '.wolai-page-ai-pi-lab-tool-policy{display:inline-flex;align-items:center;border-radius:999px;border:1px solid #ded7ca;background:#fff;padding:1px 6px;font-size:10px;color:#6f6557;margin-left:6px}' + + '.wolai-page-ai-pi-lab-tool-policy[data-policy="ask"]{border-color:#e1b870;background:#fff8e8;color:#8a5c13}' + + '.wolai-page-ai-pi-lab-tool-policy[data-approved="true"]{border-color:#83b99a;background:#eef9f1;color:#27613b}' + + '.wolai-page-ai-pi-lab-tool-policy[data-approved="false"]{border-color:#e0a39a;background:#fff0ee;color:#9f352c}' + + '.wolai-page-ai-pi-lab-abort-note{margin-top:8px;border:1px solid #f2c4bd;border-radius:8px;background:#fff8f7;color:#9f352c;padding:7px 9px;font-size:12px}' + + '.wolai-page-ai-pi-lab-message-actions{position:absolute;right:8px;top:8px;display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s ease}' + + '.wolai-page-ai-pi-lab-bubble:hover .wolai-page-ai-pi-lab-message-actions,.wolai-page-ai-pi-lab-bubble:focus-within .wolai-page-ai-pi-lab-message-actions{opacity:1;pointer-events:auto}' + + '.wolai-page-ai-pi-lab-message-actions button{width:28px;height:28px;border:1px solid #e1e4eb;border-radius:8px;background:#fff;color:#4a4f58;padding:0;display:grid;place-items:center;cursor:pointer;box-shadow:0 4px 14px rgba(15,23,42,.08)}' + + '.wolai-page-ai-pi-lab-message-actions button:hover{background:#f8fafc}' + + '.wolai-page-ai-pi-lab-message-actions svg{width:15px;height:15px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}' + '.wolai-page-ai-pi-lab-citation{display:inline-flex;align-items:center;gap:6px;width:max-content;max-width:100%;border:1px solid #d9e6ff;border-radius:999px;background:#f3f7ff;color:#2456a6;text-decoration:none;padding:4px 8px;font-size:11px}' + '.wolai-page-ai-pi-lab-diff{display:inline-flex;align-items:center;width:max-content;max-width:100%;margin-top:8px;border:1px solid #f0dcb7;border-radius:999px;background:#fff7e8;color:#865b13;padding:4px 8px;font-size:11px}' + - '.wolai-page-ai-pi-lab-composer{flex:0 0 auto;border:1px solid #e4e7ef;border-top:0;border-radius:0 0 22px 22px;background:#fff;box-shadow:0 14px 34px rgba(15,23,42,.09);overflow:hidden}' + - '.wolai-page-ai-pi-lab-input{width:100%;min-height:94px;max-height:190px;box-sizing:border-box;border:0;resize:none;padding:20px 22px 10px;font:18px/1.45 inherit;color:#242424;background:#f7f8fb;outline:none}' + + '.wolai-page-ai-pi-lab-composer{position:relative;flex:0 0 auto;border:1px solid #e4e7ef;border-top:0;border-radius:0 0 16px 16px;background:#fff;box-shadow:0 14px 34px rgba(15,23,42,.09);overflow:visible}' + + '.wolai-page-ai-pi-lab-input-wrap{position:relative;background:#f7f8fb}' + + '.wolai-page-ai-pi-lab-input{width:100%;min-height:98px;max-height:190px;box-sizing:border-box;border:0;resize:none;padding:18px 62px 12px 22px;font:18px/1.45 inherit;color:#242424;background:transparent;outline:none}' + '.wolai-page-ai-pi-lab-input::placeholder{color:#b6bbc5}' + - '.wolai-page-ai-pi-lab-composer-bar{height:48px;display:flex;align-items:center;gap:8px;padding:0 14px;border-top:1px solid #eef0f5;background:#fff}' + '.wolai-page-ai-pi-lab-quick{height:24px;border:1px solid #e5e0d8;border-radius:999px;background:#f8f7f3;color:#5d594f;font-size:11px;padding:0 8px;cursor:pointer}' + - '.wolai-page-ai-pi-lab-modebar{height:52px;display:flex;align-items:center;gap:10px;padding:0 14px;border-top:1px solid #eef0f5;background:#fff}' + - '.wolai-page-ai-pi-lab-square{width:36px;height:36px;border:1px solid #e1e4eb;border-radius:9px;background:#fff;color:#242833;display:grid;place-items:center;font-size:19px;cursor:pointer}' + + '.wolai-page-ai-pi-lab-modebar{min-height:46px;display:flex;align-items:center;gap:8px;padding:6px 12px;border-top:1px solid #eef0f5;background:#fff;box-sizing:border-box}' + + '.wolai-page-ai-pi-lab-square{width:34px;height:34px;flex:0 0 34px;border:1px solid #e1e4eb;border-radius:9px;background:#fff;color:#242833;display:grid;place-items:center;cursor:pointer}' + + '.wolai-page-ai-pi-lab-square:hover{background:#f8fafc}' + + '.wolai-page-ai-pi-lab-square[data-active="true"],.wolai-page-ai-pi-lab-chip-button[data-active="true"]{border-color:#9ec5fe;background:#eef6ff;color:#1558a6}' + + '.wolai-page-ai-pi-lab-chip-button{height:34px;display:inline-flex;align-items:center;gap:6px;border:1px solid #e1e4eb;border-radius:10px;background:#fff;color:#4a4f58;padding:0 10px;font:12px/1 inherit;white-space:nowrap;cursor:pointer}' + + '.wolai-page-ai-pi-lab-chip-button svg{width:14px;height:14px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}' + + '.wolai-page-ai-pi-lab-chip-button[data-tone="warn"]{border-color:#f0d8aa;background:#fff9ed;color:#865b13}.wolai-page-ai-pi-lab-chip-button[data-tone="danger"]{border-color:#f2c4bd;background:#fff8f7;color:#9f352c}.wolai-page-ai-pi-lab-chip-button[data-tone="ok"]{border-color:#bfe6ac;background:#f4fff0;color:#27613b}' + + '.wolai-page-ai-pi-lab-thinking-control{height:34px;border:1px solid #e1e4eb;border-radius:10px;background:#fff;color:#4a4f58;padding:0 8px;font:12px/1 inherit;max-width:104px}' + + '.wolai-page-ai-pi-lab-permission-wrap{position:relative;display:inline-flex;flex:0 0 auto}' + + '.wolai-page-ai-pi-lab-permission-menu{position:absolute;left:0;bottom:40px;z-index:18;width:min(306px,calc(100vw - 28px));border:1px solid #dfe3ea;border-radius:12px;background:#fff;box-shadow:0 18px 46px rgba(15,23,42,.22);padding:6px;display:none;color:#242424}' + + '.wolai-page-ai-pi-lab-permission-wrap[data-open="true"] .wolai-page-ai-pi-lab-permission-menu{display:block}' + + '.wolai-page-ai-pi-lab-permission-option{width:100%;min-height:58px;border:0;border-radius:9px;background:#fff;color:#242424;display:grid;grid-template-columns:22px 1fr 18px;gap:10px;align-items:center;text-align:left;padding:8px 9px;font:13px/1.25 inherit;cursor:pointer}' + + '.wolai-page-ai-pi-lab-permission-option:hover{background:#f7f8fb}.wolai-page-ai-pi-lab-permission-option[data-active="true"]{background:#f3f6fb}' + + '.wolai-page-ai-pi-lab-permission-option svg{width:17px;height:17px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round;color:#5d6470}' + + '.wolai-page-ai-pi-lab-permission-option strong{display:block;font-size:13px;font-weight:650}.wolai-page-ai-pi-lab-permission-option small{display:block;margin-top:4px;color:#6b7280;font-size:12px;line-height:1.25}.wolai-page-ai-pi-lab-permission-check{color:#6b7280;text-align:right}' + + '.wolai-page-ai-pi-lab-action-menu{position:absolute;left:12px;bottom:52px;z-index:16;width:min(300px,calc(100% - 24px));border:1px solid #dfe3ea;border-radius:12px;background:#fff;box-shadow:0 18px 46px rgba(15,23,42,.22);padding:6px;display:none;color:#242424}' + + '.wolai-page-ai-pi-lab-composer[data-menu-open="true"] .wolai-page-ai-pi-lab-action-menu{display:block}' + + '.wolai-page-ai-pi-lab-menu-section{padding:5px 7px 4px;color:#8a9099;font-size:11px;font-weight:650}' + + '.wolai-page-ai-pi-lab-menu-item{width:100%;min-height:34px;border:0;border-radius:8px;background:#fff;color:#242424;display:flex;align-items:center;gap:9px;text-align:left;padding:0 9px;font:13px/1.2 inherit;cursor:pointer}' + + '.wolai-page-ai-pi-lab-menu-item:hover{background:#f7f8fb}.wolai-page-ai-pi-lab-menu-item[data-active="true"]{background:#eef6ff;color:#1558a6}.wolai-page-ai-pi-lab-menu-item[disabled]{opacity:.45;cursor:default}.wolai-page-ai-pi-lab-menu-item[disabled]:hover{background:#fff}' + + '.wolai-page-ai-pi-lab-menu-item svg{width:15px;height:15px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}' + + '.wolai-page-ai-pi-lab-menu-item strong{margin-left:auto;color:#1558a6;font-size:13px}' + + '.wolai-page-ai-pi-lab-menu-sep{height:1px;background:#edf0f5;margin:6px 3px}' + '.wolai-page-ai-pi-lab-segment{display:inline-flex;border:1px solid #e1e4eb;border-radius:10px;overflow:hidden;background:#fff}' + - '.wolai-page-ai-pi-lab-segment button{height:36px;border:0;background:#fff;color:#4a4f58;padding:0 16px;font:14px/1 inherit;cursor:pointer}' + + '.wolai-page-ai-pi-lab-segment button{height:36px;border:0;background:#fff;color:#4a4f58;padding:0 16px;font:14px/1 inherit;cursor:pointer;white-space:nowrap}' + '.wolai-page-ai-pi-lab-segment button[data-active=\"true\"]{background:#111827;color:#fff}' + - '.wolai-page-ai-pi-lab-model-control{height:36px;min-width:190px;border:1px solid #e1e4eb;border-radius:10px;background:#fff;color:#4a4f58;padding:0 12px;font:13px/1 inherit}' + - '.wolai-page-ai-pi-lab-budget{height:34px;border:1px solid #bfe6ac;border-radius:10px;background:#f4fff0;color:#329125;padding:0 12px;display:inline-flex;align-items:center;font-size:14px}' + + '.wolai-page-ai-pi-lab-stream-controls{display:inline-flex;align-items:center;gap:8px}' + + '.wolai-page-ai-pi-lab-queue-count{height:22px;min-width:22px;padding:0 6px;border-radius:999px;background:#eef2ff;color:#334155;display:inline-flex;align-items:center;justify-content:center;font-size:11px}' + + '.wolai-page-ai-pi-lab-model-control{height:34px;min-width:0;max-width:210px;flex:1 1 160px;border:1px solid #e1e4eb;border-radius:10px;background:#fff;color:#4a4f58;padding:0 10px;font:13px/1 inherit}' + '.wolai-page-ai-pi-lab-spacer{flex:1}' + - '.wolai-page-ai-pi-lab-send{width:52px;height:52px;border:0;border-radius:15px;background:#1d9bf0;color:#fff;display:grid;place-items:center;cursor:pointer;font-size:24px;box-shadow:0 10px 24px rgba(29,155,240,.25)}' + + '.wolai-page-ai-pi-lab-send{position:absolute;right:12px;bottom:11px;width:38px;height:38px;border:0;border-radius:12px;background:#1d9bf0;color:#fff;display:grid;place-items:center;cursor:pointer;box-shadow:0 10px 24px rgba(29,155,240,.25)}' + + '.wolai-page-ai-pi-lab-send svg{width:18px;height:18px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}' + '.wolai-page-ai-pi-lab-send:disabled{opacity:.45;cursor:default}' + - '.wolai-page-ai-pi-lab-rail{display:none}' + + '.wolai-page-ai-pi-lab-rail{position:absolute;right:14px;top:0;bottom:104px;width:260px;z-index:4;display:none;flex-direction:column;gap:8px;min-height:0;overflow:auto;padding:8px;border:1px solid #e4e7ef;border-radius:14px;background:#f7f8fb;box-shadow:0 18px 46px rgba(15,23,42,.18)}' + + '.wolai-page-ai-pi-lab-body[data-rail-open="true"] .wolai-page-ai-pi-lab-rail{display:flex}' + + '.wolai-page-ai-pi-lab-rail-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 2px 2px;color:#42403a;font-size:12px;font-weight:650}' + + '.wolai-page-ai-pi-lab-rail-close{width:24px;height:24px;border:1px solid #e1e4eb;border-radius:7px;background:#fff;color:#4a4f58;display:grid;place-items:center;cursor:pointer}' + '.wolai-page-ai-pi-lab-rail-section{border:1px solid #e5e0d8;border-radius:10px;background:#fff;overflow:hidden}' + '.wolai-page-ai-pi-lab-rail-section>summary{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;list-style:none;padding:8px 9px;font-size:11px;font-weight:650;color:#5d594f;background:#faf9f6}' + '.wolai-page-ai-pi-lab-rail-section>summary::-webkit-details-marker{display:none}' + @@ -190,14 +533,29 @@ '.wolai-page-ai-pi-lab-rail-title{padding:8px 9px;border-bottom:1px solid #eee9e0;font-size:11px;font-weight:650;color:#5d594f;background:#faf9f6}' + '.wolai-page-ai-pi-lab-tool-list{display:flex;flex-direction:column;gap:5px;padding:8px}' + '.wolai-page-ai-pi-lab-tool-pill{border:1px solid #e6e2da;border-radius:7px;background:#fff;color:#42403a;padding:5px 7px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' + + '.wolai-page-ai-pi-lab-artifacts{display:flex;flex-direction:column;gap:6px;padding:8px;max-height:250px;overflow:auto}' + + '.wolai-page-ai-pi-lab-artifact{border:1px solid #e6e2da;border-radius:8px;background:#fff;color:#42403a;padding:7px;font-size:11px;line-height:1.42;overflow:hidden}' + + '.wolai-page-ai-pi-lab-artifact[data-kind="citation"]{border-color:#d9e6ff;background:#f7faff}.wolai-page-ai-pi-lab-artifact[data-kind="diff"]{border-color:#f0dcb7;background:#fff9ed}.wolai-page-ai-pi-lab-artifact[data-kind="mcp"]{border-color:#dfe6f2;background:#fafcff}' + + '.wolai-page-ai-pi-lab-artifact strong{display:block;font-size:11px;font-weight:700;color:#2f343b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.wolai-page-ai-pi-lab-artifact span{display:block;margin-top:3px;color:#6f6a60;word-break:break-word}.wolai-page-ai-pi-lab-artifact-actions{display:flex;flex-wrap:wrap;gap:5px}.wolai-page-ai-pi-lab-artifact button,.wolai-page-ai-pi-lab-artifact a,.wolai-page-ai-pi-lab-rail-action{display:inline-flex;align-items:center;height:24px;margin-top:6px;border:1px solid #e1e4eb;border-radius:7px;background:#fff;color:#315d9f;text-decoration:none;padding:0 7px;font-size:11px;cursor:pointer}' + + '.wolai-page-ai-pi-lab-rail-action{margin:8px 8px 0}.wolai-page-ai-pi-lab-rail-action:hover,.wolai-page-ai-pi-lab-artifact button:hover,.wolai-page-ai-pi-lab-artifact a:hover{background:#f8fafc}' + '.wolai-page-ai-pi-lab-receipts{display:flex;flex-direction:column;gap:5px;padding:8px;max-height:170px;overflow:auto}' + '.wolai-page-ai-pi-lab-receipt{border-radius:7px;background:#f8f7f3;border:1px solid #e6e2da;padding:6px;font-size:11px;color:#4d4941}' + '.wolai-page-ai-pi-lab-receipt[data-allowed="false"]{background:#fff0ee;border-color:#f2c4bd;color:#9f352c}' + + '.wolai-page-ai-pi-lab-ui-backdrop{position:relative;z-index:12;display:block;padding:10px 12px 0;background:#fff;pointer-events:none}' + + '.wolai-page-ai-pi-lab-ui-dialog{width:100%;border:1px solid #dfe4ec;border-radius:12px;background:#fff;box-shadow:0 10px 28px rgba(15,23,42,.12);overflow:hidden;color:#242424;pointer-events:auto}' + + '.wolai-page-ai-pi-lab-ui-dialog[data-collapsed="true"] .wolai-page-ai-pi-lab-ui-dialog-body,.wolai-page-ai-pi-lab-ui-dialog[data-collapsed="true"] .wolai-page-ai-pi-lab-ui-dialog-actions{display:none}' + + '.wolai-page-ai-pi-lab-ui-dialog-header{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:start;padding:11px 12px 9px;border-bottom:1px solid #edf0f5;background:#fafbfc}.wolai-page-ai-pi-lab-ui-dialog-header strong{display:block;font-size:13px;font-weight:720;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wolai-page-ai-pi-lab-ui-dialog-header span{display:block;margin-top:3px;font-size:12px;color:#6f6a60;white-space:pre-wrap}.wolai-page-ai-pi-lab-ui-collapse{width:28px;height:28px;border:1px solid #e1e4eb;border-radius:8px;background:#fff;color:#4a4f58;cursor:pointer}' + + '.wolai-page-ai-pi-lab-ui-dialog-body{padding:10px 12px;display:flex;flex-direction:column;gap:8px;max-height:min(320px,38vh);overflow:auto}.wolai-page-ai-pi-lab-ui-dialog-body input,.wolai-page-ai-pi-lab-ui-dialog-body textarea{width:100%;box-sizing:border-box;border:1px solid #ddd8cf;border-radius:8px;background:#fff;color:#242424;padding:8px 9px;font:13px/1.45 inherit;outline:none}.wolai-page-ai-pi-lab-ui-dialog-body textarea{min-height:76px;resize:vertical}' + + '.wolai-page-ai-pi-lab-ui-question{border:1px solid #edf0f5;border-radius:10px;background:#fff;padding:9px}.wolai-page-ai-pi-lab-ui-question-title{font-size:12px;font-weight:700;color:#2f343b;margin-bottom:3px}.wolai-page-ai-pi-lab-ui-question-prompt{font-size:12px;color:#6f6a60;white-space:pre-wrap;margin-bottom:7px}.wolai-page-ai-pi-lab-ui-option{width:100%;min-height:34px;border:1px solid #e1e4eb;border-radius:8px;background:#fff;color:#242424;text-align:left;padding:7px 9px;cursor:pointer;display:flex;align-items:flex-start;gap:8px}.wolai-page-ai-pi-lab-ui-option:hover,.wolai-page-ai-pi-lab-ui-option[data-selected="true"]{background:#f3f7ff;border-color:#bcd7ff}.wolai-page-ai-pi-lab-ui-option small{display:block;margin-top:2px;color:#737a86;font-size:11px;line-height:1.35}.wolai-page-ai-pi-lab-ui-option-mark{flex:0 0 auto;color:#5d6675}.wolai-page-ai-pi-lab-ui-note{border-top:1px solid #edf0f5;padding-top:8px}.wolai-page-ai-pi-lab-ui-note label{display:block;margin-bottom:4px;font-size:11px;color:#737a86}' + + '.wolai-page-ai-pi-lab-ui-dialog-actions{display:flex;justify-content:flex-end;gap:8px;padding:9px 12px 11px;border-top:1px solid #edf0f5}.wolai-page-ai-pi-lab-ui-dialog-actions button{height:30px;border:1px solid #ddd8cf;border-radius:8px;background:#fff;color:#4a4f58;padding:0 11px;cursor:pointer}.wolai-page-ai-pi-lab-ui-dialog-actions button[data-primary="true"]{border-color:#111827;background:#111827;color:#fff}' + + '.wolai-page-ai-pi-lab-ui-tui{margin:0;padding:8px 9px;border:1px solid #edf0f5;border-radius:8px;background:#fafbfc;color:#242833;white-space:pre-wrap;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:auto}.wolai-page-ai-pi-lab-ui-tui-options{display:flex;flex-direction:column;gap:6px}.wolai-page-ai-pi-lab-ui-option[data-custom-selected="true"]{background:#eef6ff;border-color:#9ec5fe}' + + '.wolai-page-ai-pi-lab-toast-stack{position:absolute;left:18px;right:18px;bottom:166px;z-index:13;display:flex;flex-direction:column;align-items:flex-end;gap:8px;pointer-events:none}.wolai-page-ai-pi-lab-toast{width:min(340px,100%);border:1px solid #dbe3ef;border-radius:10px;background:#fff;color:#242424;box-shadow:0 12px 34px rgba(15,23,42,.18);padding:10px 12px;font-size:13px;line-height:1.45;pointer-events:auto}.wolai-page-ai-pi-lab-toast[data-type="warning"]{border-color:#f0dcb7;background:#fff9ed}.wolai-page-ai-pi-lab-toast[data-type="error"]{border-color:#f2c4bd;background:#fff8f7}.wolai-page-ai-pi-lab-toast[data-type="success"]{border-color:#bfe6ac;background:#f4fff0}' + '.wolai-page-ai-pi-lab-diagnostics{display:none;border-top:1px solid #e6e2da;background:#fff;flex:0 0 auto}' + '.wolai-page-ai-pi-lab-drawer[data-config-open="true"] .wolai-page-ai-pi-lab-diagnostics{display:block}' + '.wolai-page-ai-pi-lab-diagnostics>summary{cursor:pointer;padding:8px 12px;font-size:11px;color:#6f6a60}' + '.wolai-page-ai-pi-lab-diagnostics pre{margin:0;padding:0 12px 10px;white-space:pre-wrap;word-break:break-word;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;color:#5d594f}' + - '@media (max-width: 760px){.wolai-page-ai-pi-lab-drawer{left:10px;right:10px;top:58px;bottom:10px;width:auto}.wolai-page-ai-pi-lab-body{grid-template-columns:1fr}.wolai-page-ai-pi-lab-rail{display:none}.wolai-page-ai-pi-lab-config{grid-template-columns:1fr}}'; + '@media (max-width: 760px){.wolai-page-ai-pi-lab-drawer{left:10px;right:10px;top:58px;bottom:10px;width:auto}.wolai-page-ai-pi-lab-history-panel{width:min(320px,calc(100vw - 34px))}.wolai-page-ai-pi-lab-rail{left:14px;right:14px;top:auto;bottom:132px;width:auto;max-height:min(360px,calc(100% - 164px))}.wolai-page-ai-pi-lab-config{grid-template-columns:1fr}}' + + '@media (max-width: 520px){.wolai-page-ai-pi-lab-topbar{height:54px;padding:0 14px}.wolai-page-ai-pi-lab-title strong{font-size:21px}.wolai-page-ai-pi-lab-commandbar{height:50px;margin:0 14px 12px;gap:6px;overflow:hidden}.wolai-page-ai-pi-lab-history-panel{top:116px;left:12px;right:12px;max-height:min(300px,calc(100% - 300px))}.wolai-page-ai-pi-lab-history-row{grid-template-columns:1fr}.wolai-page-ai-pi-lab-history-actions{justify-content:flex-start}.wolai-page-ai-pi-lab-input{min-height:88px;padding:16px 58px 10px 18px;font-size:16px}.wolai-page-ai-pi-lab-modebar{min-height:48px;flex-wrap:nowrap;gap:6px;padding:6px 10px}.wolai-page-ai-pi-lab-model-control{flex:1 1 130px;min-width:92px;max-width:none}.wolai-page-ai-pi-lab-toast-stack{left:14px;right:14px;bottom:154px}.wolai-page-ai-pi-lab-toast{width:100%}}'; document.head.appendChild(style); } @@ -211,6 +569,150 @@ .replace(/'/g, '''); } + function escapeAttr(str) { + return escapeHtml(str).replace(/`/g, '`'); + } + + function renderMarkdownInline(text) { + var placeholders = []; + function stash(html) { + var key = '\u0000MD' + placeholders.length + '\u0000'; + placeholders.push(html); + return key; + } + var value = escapeHtml(text || ''); + value = value.replace(/`([^`]+)`/g, function (_, code) { + return stash('' + code + ''); + }); + value = value.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, function (_, label, href) { + return stash('' + label + ''); + }); + value = value.replace(/\*\*([^*]+)\*\*/g, '$1'); + value = value.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); + placeholders.forEach(function (html, index) { + value = value.replace(new RegExp('\u0000MD' + index + '\u0000', 'g'), html); + }); + return value; + } + + function isTableDivider(line) { + return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line || ''); + } + + function splitTableRow(line) { + var trimmed = String(line || '').trim(); + if (trimmed.charAt(0) === '|') trimmed = trimmed.slice(1); + if (trimmed.charAt(trimmed.length - 1) === '|') trimmed = trimmed.slice(0, -1); + return trimmed.split('|').map(function (cell) { return cell.trim(); }); + } + + function renderMarkdownTable(lines, start) { + var header = splitTableRow(lines[start]); + var rows = []; + var i = start + 2; + while (i < lines.length && /\|/.test(lines[i] || '') && String(lines[i] || '').trim()) { + rows.push(splitTableRow(lines[i])); + i += 1; + } + return { + html: '' + header.map(function (cell) { + return ''; + }).join('') + '' + rows.map(function (row) { + return '' + header.map(function (_, index) { + return ''; + }).join('') + ''; + }).join('') + '
' + renderMarkdownInline(cell) + '
' + renderMarkdownInline(row[index] || '') + '
', + next: i, + }; + } + + function renderMarkdownBlock(text) { + var lines = String(text || '').replace(/\r\n/g, '\n').split('\n'); + var html = []; + var paragraph = []; + var list = null; + var inCode = false; + var codeLines = []; + var codeLang = ''; + function flushParagraph() { + if (!paragraph.length) return; + html.push('

' + renderMarkdownInline(paragraph.join(' ')) + '

'); + paragraph = []; + } + function flushList() { + if (!list) return; + html.push('<' + list.type + '>' + list.items.map(function (item) { + return '
  • ' + renderMarkdownInline(item) + '
  • '; + }).join('') + ''); + list = null; + } + for (var i = 0; i < lines.length; i += 1) { + var line = lines[i]; + var trimmed = line.trim(); + var fence = trimmed.match(/^```([A-Za-z0-9_-]*)\s*$/); + if (fence) { + if (inCode) { + html.push('
    ' + escapeHtml(codeLines.join('\n')) + '
    '); + inCode = false; + codeLines = []; + codeLang = ''; + } else { + flushParagraph(); + flushList(); + inCode = true; + codeLang = fence[1] || ''; + } + continue; + } + if (inCode) { + codeLines.push(line); + continue; + } + if (!trimmed) { + flushParagraph(); + flushList(); + continue; + } + if (i + 1 < lines.length && /\|/.test(line) && isTableDivider(lines[i + 1])) { + flushParagraph(); + flushList(); + var renderedTable = renderMarkdownTable(lines, i); + html.push(renderedTable.html); + i = renderedTable.next - 1; + continue; + } + var heading = trimmed.match(/^(#{1,3})\s+(.+)$/); + if (heading) { + flushParagraph(); + flushList(); + html.push('' + renderMarkdownInline(heading[2]) + ''); + continue; + } + var quote = trimmed.match(/^>\s?(.*)$/); + if (quote) { + flushParagraph(); + flushList(); + html.push('
    ' + renderMarkdownInline(quote[1]) + '
    '); + continue; + } + var unordered = trimmed.match(/^[-*+]\s+(.+)$/); + var ordered = trimmed.match(/^\d+\.\s+(.+)$/); + if (unordered || ordered) { + flushParagraph(); + var type = ordered ? 'ol' : 'ul'; + if (!list || list.type !== type) flushList(); + if (!list) list = { type: type, items: [] }; + list.items.push((unordered || ordered)[1]); + continue; + } + paragraph.push(trimmed); + } + if (inCode) html.push('
    ' + escapeHtml(codeLines.join('\n')) + '
    '); + flushParagraph(); + flushList(); + return html.join(''); + } + function el(tag, attrs, children) { var elem = document.createElement(tag); if (attrs) { @@ -231,6 +733,221 @@ return elem; } + function compactJson(value) { + if (value == null || value === '') return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch (_) { + return String(value); + } + } + + function extractResultText(value) { + if (value == null) return ''; + if (typeof value === 'string') return value; + var content = value.content; + if (Array.isArray(content)) { + var text = content.map(function (part) { + if (!part) return ''; + if (typeof part === 'string') return part; + if (typeof part.text === 'string') return part.text; + return ''; + }).filter(Boolean).join(''); + if (text) return text; + } + return compactJson(value); + } + + function truncateSingleLine(value, limit) { + var text = String(value || '').replace(/\s+/g, ' ').trim(); + var max = limit || 120; + return text.length > max ? text.slice(0, max - 1) + '…' : text; + } + + function copyTextToClipboard(text) { + text = String(text || ''); + if (!text) return Promise.resolve(false); + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text).then(function () { return true; }).catch(function () { return false; }); + } + return Promise.resolve(false); + } + + function messageToMarkdown(msg) { + if (!msg) return ''; + var lines = []; + if (msg.text) lines.push(String(msg.text)); + if (Array.isArray(msg.citations) && msg.citations.length) { + lines.push('', 'References:'); + msg.citations.forEach(function (cit, index) { + lines.push((index + 1) + '. ' + (cit.title || cit.source || 'Citation') + (cit.url ? ' - ' + cit.url : '')); + }); + } + if (msg.diffSummary && Array.isArray(msg.diffSummary.files) && msg.diffSummary.files.length) { + lines.push('', 'Changed files:'); + msg.diffSummary.files.forEach(function (file) { lines.push('- ' + file); }); + } + if (Array.isArray(msg.toolCalls) && msg.toolCalls.length) { + lines.push('', 'Tool calls:'); + msg.toolCalls.forEach(function (tc) { + lines.push('- ' + toolDisplayName({ toolName: tc.name, args: tc.args, details: tc.details }) + ' · ' + (tc.status || '')); + }); + } + return lines.join('\n').trim(); + } + + function currentSessionEventsUrl() { + if (!piLabState.sessionId) return ''; + return API.SESSIONS + '/' + encodeURIComponent(piLabState.sessionId) + '/events?limit=1000'; + } + + function openCurrentSessionJsonl() { + var url = currentSessionEventsUrl(); + if (!url) { + showPiToast('No Pi session to open', 'warning'); + return Promise.resolve(false); + } + return fetch(url, { credentials: 'same-origin', cache: 'no-store' }) + .then(function (response) { + if (!response.ok) throw new Error('Session events failed: ' + response.status); + return response.json(); + }) + .then(function (payload) { + var lines = (Array.isArray(payload.events) ? payload.events : []).map(function (event) { + return JSON.stringify({ + eventType: event.eventType, + createdAt: event.createdAt, + payload: event.payload || {}, + }); + }).join('\n'); + var blob = new Blob([lines + (lines ? '\n' : '')], { type: 'application/x-ndjson;charset=utf-8' }); + var objectUrl = URL.createObjectURL(blob); + window.open(objectUrl, '_blank', 'noopener'); + setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 30000); + showPiToast('Session JSONL opened', 'success'); + return true; + }) + .catch(function (error) { + updateDiagnostics(error.message || '打开 session JSONL 失败'); + showPiToast('Open session JSONL failed', 'error'); + return false; + }); + } + + function normalizeToolCallId(payload) { + payload = payload || {}; + return String(payload.toolCallId || payload.tool_call_id || payload.id || payload.callId || '').trim(); + } + + function normalizeToolName(payload) { + payload = payload || {}; + return String(payload.toolName || payload.tool_name || payload.name || payload.tool || 'tool').trim(); + } + + function parseMcpArgs(args) { + if (!args) return {}; + if (typeof args === 'string') { + try { + return JSON.parse(args); + } catch (_) { + return { raw: args }; + } + } + return args; + } + + function toolDisplayName(payload) { + var name = normalizeToolName(payload); + var args = parseMcpArgs(payload && payload.args); + var details = payload && payload.details ? payload.details : {}; + var server = String(args.server || details.server || '').trim(); + var tool = String(args.tool || details.tool || '').trim(); + if (name === 'mcp' && server) return 'mcp:' + server + (tool ? '/' + tool : '/list'); + return name; + } + + function assistantToolCallPayload(msgData) { + if (!msgData) return null; + var toolCall = msgData.toolCall || null; + if (!toolCall && msgData.partial && msgData.partial.type === 'toolCall') toolCall = msgData.partial; + if (!toolCall && msgData.toolName) toolCall = msgData; + if (!toolCall && msgData.name) toolCall = msgData; + if (!toolCall || !(toolCall.name || toolCall.toolName || toolCall.tool_name || toolCall.id || toolCall.toolCallId)) return null; + return { + toolCallId: toolCall.toolCallId || toolCall.id || msgData.toolCallId || msgData.id || '', + toolName: toolCall.toolName || toolCall.tool_name || toolCall.name || msgData.toolName || msgData.name || '', + args: toolCall.arguments || toolCall.args || msgData.arguments || msgData.args || {}, + details: msgData.details || null, + result: msgData.result || null, + }; + } + + function ensureToolCalls(msg) { + if (!msg.toolCalls) msg.toolCalls = []; + return msg.toolCalls; + } + + function upsertToolCall(raw, status, patch) { + var viewModel = applyPiRunEvent({ + type: 'tool_upsert', + payload: { + raw: raw || {}, + status: status || 'running', + patch: patch || {}, + }, + }); + updateMessages(); + return viewModel.lastToolCall; + } + + function renderToolCallCard(tc) { + var displayName = toolDisplayName({ toolName: tc.name, args: tc.args, details: tc.details }); + var resultText = extractResultText(tc.result || tc.partialResult || ''); + var summaryParts = []; + if (tc.args && Object.keys(tc.args).length) { + var args = parseMcpArgs(tc.args); + if (args.server) summaryParts.push('server=' + args.server); + if (args.tool) summaryParts.push('tool=' + args.tool); + } + if (resultText) summaryParts.push(truncateSingleLine(resultText, 140)); + var item = el('div', { + className: 'wolai-page-ai-pi-lab-tool-call', + 'data-page-ai-pi-lab-tool-card': tc.id || displayName || 'tool', + 'data-page-ai-pi-lab-tool-call': displayName || 'tool', + 'data-status': tc.status || '', + }); + var policy = tc.toolPolicy || (tc.approvalRequired ? 'ask' : ''); + var policyBadge = policy ? '' + + escapeHtml(tc.approvalRequired ? (tc.approvalConfirmed ? 'approved' : 'approval required') : policy) + '' : ''; + item.appendChild(el('div', { + className: 'wolai-page-ai-pi-lab-tool-call-header', + 'data-page-ai-pi-lab-tool-summary': 'true', + innerHTML: '' + escapeHtml(displayName || 'tool') + policyBadge + '' + escapeHtml(tc.status || '') + '', + })); + if (summaryParts.length) { + item.appendChild(el('div', { + className: 'wolai-page-ai-pi-lab-tool-call-summary-line', + 'data-page-ai-pi-lab-tool-compact-summary': 'true', + }, summaryParts.join(' · '))); + } + var hasDetails = (tc.args && Object.keys(tc.args).length) || (tc.details && Object.keys(tc.details).length) || tc.partialResult || tc.result; + if (hasDetails) { + var details = el('details', { + className: 'wolai-page-ai-pi-lab-tool-call-details', + 'data-page-ai-pi-lab-tool-details': displayName || 'tool', + }); + details.appendChild(el('summary', {}, 'Input / Output / Raw')); + if (tc.args && Object.keys(tc.args).length) details.appendChild(el('pre', { textContent: 'Input\n' + compactJson(tc.args) })); + if (tc.details && Object.keys(tc.details).length) details.appendChild(el('pre', { textContent: 'Details\n' + compactJson(tc.details) })); + if (tc.partialResult && tc.status === 'running') details.appendChild(el('pre', { textContent: 'Partial output\n' + extractResultText(tc.partialResult) })); + if (tc.result) details.appendChild(el('pre', { textContent: 'Output\n' + resultText })); + item.appendChild(details); + } + return item; + } + function normalizeSlashes(value) { return String(value || '').trim().replace(/\\/g, '/'); } @@ -252,7 +969,9 @@ if (tabDocumentId) return tabDocumentId; } var match = currentUrl().pathname.match(/^\/documents\/([^/]+)/); - return match ? decodeURIComponent(match[1]) : ''; + if (match) return decodeURIComponent(match[1]); + var missingPage = String(currentUrl().searchParams.get('missingPage') || currentUrl().searchParams.get('pageId') || '').trim(); + return missingPage ? missingPage : ''; } function currentWorkspaceId() { @@ -260,13 +979,28 @@ } function currentRootUri() { - return currentUrl().searchParams.get('rootUri') || ''; + var fromUrl = String(currentUrl().searchParams.get('rootUri') || '').trim(); + if (fromUrl) return fromUrl; + var fromBody = document.body instanceof HTMLElement ? String(document.body.getAttribute('data-mnote-root-uri') || '').trim() : ''; + if (fromBody) return fromBody; + return String((piLabState.session && piLabState.session.rootUri) || '').trim(); } function localMarkdownRelativePathFromDocumentId(documentId) { var value = String(documentId || '').trim(); if (!value.startsWith('local-md:')) return ''; - return value.slice('local-md:'.length).replace(/~2F/g, '/'); + var encoded = value.slice('local-md:'.length); + try { + return decodeURIComponent(encoded.replace(/~([0-9A-Fa-f]{2})/g, '%$1')); + } catch (_) { + return encoded.replace(/~2F/gi, '/'); + } + } + + function cssEscape(value) { + var text = String(value || ''); + if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text); + return text.replace(/["\\]/g, '\\$&'); } function currentSelectionText() { @@ -295,13 +1029,48 @@ } } + function activeFileTreeWorkspacePath(documentId) { + try { + var relativePath = localMarkdownRelativePathFromDocumentId(documentId); + var selector = '#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'; + if (relativePath) selector += '[data-local-relative-path="' + cssEscape(relativePath) + '"]'; + var row = document.querySelector(selector); + var reader = window.__mnoteFileTreeRuntime && typeof window.__mnoteFileTreeRuntime.readWorkspacePathFromRow === 'function' + ? window.__mnoteFileTreeRuntime.readWorkspacePathFromRow + : null; + if (row instanceof HTMLElement && reader) { + var workspacePath = reader(row); + if (workspacePath && typeof workspacePath === 'object') { + return Object.assign({}, workspacePath, { + relativePath: workspacePath.relativePath || row.getAttribute('data-local-relative-path') || '', + rootUri: workspacePath.rootUri || row.getAttribute('data-root-uri') || '', + sourceKind: workspacePath.sourceKind || row.getAttribute('data-source-kind') || '', + documentId: workspacePath.documentId || row.getAttribute('data-document-id') || documentId || '', + }); + } + } + if (row instanceof HTMLElement) { + return { + relativePath: row.getAttribute('data-local-relative-path') || '', + rootUri: row.getAttribute('data-root-uri') || '', + sourceKind: row.getAttribute('data-source-kind') || '', + documentId: row.getAttribute('data-document-id') || documentId || '', + }; + } + } catch (_) {} + return null; + } + function currentPageContext() { var active = activeEditorSnapshot() || {}; - var workspacePath = active.workspacePath && typeof active.workspacePath === 'object' ? active.workspacePath : {}; - var documentId = String(workspacePath.documentId || active.documentId || currentDocumentId() || '').trim(); - var pagePath = normalizeSlashes(workspacePath.relativePath || workspacePath.path || active.relativePath || active.path || localMarkdownRelativePathFromDocumentId(documentId)); - var rootUri = String(workspacePath.rootUri || active.rootUri || currentRootUri() || '').trim(); - var workspaceId = String(workspacePath.workspaceId || active.workspaceId || currentWorkspaceId() || '').trim(); + var activeWorkspacePath = active.workspacePath && typeof active.workspacePath === 'object' ? active.workspacePath : {}; + var documentId = String(activeWorkspacePath.documentId || active.documentId || currentDocumentId() || '').trim(); + var fileTreeWorkspacePath = activeFileTreeWorkspacePath(documentId) || {}; + var workspacePath = Object.assign({}, activeWorkspacePath, fileTreeWorkspacePath); + var session = piLabState.session || {}; + var pagePath = normalizeSlashes(workspacePath.relativePath || workspacePath.path || active.relativePath || active.path || localMarkdownRelativePathFromDocumentId(documentId) || session.pagePath || ''); + var rootUri = String(workspacePath.rootUri || active.rootUri || currentRootUri() || session.rootUri || '').trim(); + var workspaceId = String(workspacePath.workspaceId || active.workspaceId || currentWorkspaceId() || session.workspaceId || '').trim(); var titleNode = document.querySelector('[data-page-title-current="true"]') || document.querySelector('.wolai-breadcrumb-current'); var title = String(active.title || workspacePath.title || (titleNode && titleNode.textContent) || document.title || '').trim(); var selection = currentSelectionText(); @@ -315,6 +1084,64 @@ }; } + function currentFolderPathFromPagePath(pagePath) { + var normalized = normalizeSlashes(pagePath || '').replace(/^\/+/, ''); + if (!normalized) return null; + var parts = normalized.split('/').filter(Boolean); + if (parts.length <= 1) return ''; + parts.pop(); + return parts.join('/'); + } + + function contextSelectionState() { + if (!piLabState.contextSelection || typeof piLabState.contextSelection !== 'object') { + piLabState.contextSelection = {}; + } + if (typeof piLabState.contextSelection.currentPage !== 'boolean') piLabState.contextSelection.currentPage = false; + if (typeof piLabState.contextSelection.currentFolder !== 'boolean') piLabState.contextSelection.currentFolder = false; + if (typeof piLabState.contextSelection.selection !== 'boolean') piLabState.contextSelection.selection = false; + if (typeof piLabState.contextSelection.lightrag !== 'boolean') piLabState.contextSelection.lightrag = false; + return piLabState.contextSelection; + } + + function selectedContextRefs() { + var selection = contextSelectionState(); + var refs = []; + if (selection.currentPage) refs.push('current_page'); + if (selection.currentFolder) refs.push('folder'); + if (selection.selection) refs.push('selection'); + if (selection.lightrag) refs.push('lightrag'); + return refs; + } + + function selectedContextPayload(context) { + context = context || currentPageContext(); + var selection = contextSelectionState(); + var folderPath = currentFolderPathFromPagePath(context.pagePath); + return { + refs: selectedContextRefs(), + currentPage: selection.currentPage ? { + rootUri: context.rootUri || null, + workspaceId: context.workspaceId || null, + pagePath: context.pagePath || null, + pageTitle: context.pageTitle || null, + } : null, + currentFolder: selection.currentFolder ? { + rootUri: context.rootUri || null, + workspaceId: context.workspaceId || null, + folderPath: folderPath === null ? null : folderPath, + } : null, + selection: selection.selection ? { + text: context.selection || piLabState.selectionText || '', + source: 'mnote_sidebar_host', + } : null, + lightrag: selection.lightrag ? { + enabled: true, + provider: 'lightrag', + } : null, + }; + } + function refreshSelectionSummary() { var selection = currentSelectionText(); if (selection) piLabState.selectionText = selection; @@ -327,14 +1154,36 @@ var context = currentPageContext(); refreshSelectionSummary(); if (!piLabState.session) piLabState.session = {}; - if (context.pagePath && !piLabState.session.pagePath) piLabState.session.pagePath = context.pagePath; - if (context.pageTitle && !piLabState.session.pageTitle) piLabState.session.pageTitle = context.pageTitle; - if (context.rootUri && !piLabState.session.rootUri) piLabState.session.rootUri = context.rootUri; - if (context.workspaceId && !piLabState.session.workspaceId) piLabState.session.workspaceId = context.workspaceId; + if (context.pagePath) piLabState.session.pagePath = context.pagePath; + if (context.pageTitle) piLabState.session.pageTitle = context.pageTitle; + if (context.rootUri) piLabState.session.rootUri = context.rootUri; + if (context.workspaceId) piLabState.session.workspaceId = context.workspaceId; + var folderPath = currentFolderPathFromPagePath(context.pagePath); + if (folderPath !== null) piLabState.session.folderPath = folderPath; updateContextStrip(); return context; } + function setContextQuickActive(kind, active) { + var selection = contextSelectionState(); + if (kind === 'read-page') selection.currentPage = active !== false; + if (kind === 'current-folder') selection.currentFolder = active !== false; + if (kind === 'selection') selection.selection = active !== false; + if (kind === 'rag') selection.lightrag = active !== false; + updateContextStrip(); + updateButtons(); + } + + function toggleContextQuick(kind) { + var selection = contextSelectionState(); + var current = false; + if (kind === 'read-page') current = selection.currentPage; + else if (kind === 'current-folder') current = selection.currentFolder; + else if (kind === 'selection') current = selection.selection; + else if (kind === 'rag') current = selection.lightrag; + setContextQuickActive(kind, !current); + } + function currentModelLabel() { var provider = piLabState.modelProvider || piLabState.defaultModelProvider || DEFAULT_MODEL_PROVIDER; var modelId = piLabState.modelId || piLabState.defaultModelId || DEFAULT_MODEL_ID; @@ -342,17 +1191,270 @@ return [provider, modelId].filter(Boolean).join('/'); } + function normalizeThinkingLevel(value) { + var level = String(value || '').trim().toLowerCase(); + return ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'].indexOf(level) >= 0 ? level : DEFAULT_THINKING_LEVEL; + } + + function currentThinkingLabel() { + var labels = { + off: '关', + minimal: '极低', + low: '低', + medium: '中', + high: '高', + xhigh: '最高', + }; + return labels[normalizeThinkingLevel(piLabState.thinkingLevel || piLabState.defaultThinkingLevel)] || '中'; + } + + function normalizePermissionMode(value) { + var mode = String(value || '').trim().toLowerCase(); + if (mode === 'ask') return 'confirm'; + return ['confirm', 'auto_edit', 'plan', 'full_access'].indexOf(mode) >= 0 ? mode : DEFAULT_PERMISSION_MODE; + } + + function permissionModeOptions() { + return [ + { mode: 'confirm', icon: 'status', label: '变更前确认', description: '改文件、执行命令前先问我。' }, + { mode: 'auto_edit', icon: 'zap', label: '自动编辑', description: '授权目录内读写自动放行。' }, + { mode: 'plan', icon: 'settings', label: '计划模式', description: '只读分析,禁止写入和命令。' }, + { mode: 'full_access', icon: 'shield', label: '完全访问', description: '本会话内尽量不再确认。' }, + ]; + } + + function permissionModeLabel(mode) { + mode = normalizePermissionMode(mode); + var found = permissionModeOptions().filter(function (item) { return item.mode === mode; })[0]; + return found ? found.label : '变更前确认'; + } + + function currentRuntimePolicy() { + return (piLabState.session && (piLabState.session.runtimePolicySnapshot || piLabState.session.runtimePolicy)) || {}; + } + + function toolPolicyValues() { + var policy = currentRuntimePolicy(); + var policies = policy.mnoteToolPolicies || policy.mnote_tool_policies || {}; + return Object.keys(policies).map(function (key) { return String(policies[key] || '').toLowerCase(); }).filter(Boolean); + } + + function permissionSummary() { + var selectedMode = normalizePermissionMode(piLabState.permissionMode); + if (selectedMode === 'plan') return { label: '计划模式', tone: 'warn' }; + if (selectedMode === 'auto_edit') return { label: '自动编辑', tone: 'ok' }; + if (selectedMode === 'full_access') return { label: '完全访问', tone: 'ok' }; + var values = toolPolicyValues(); + var hasAsk = values.indexOf('ask') >= 0; + var hasDeny = values.indexOf('deny') >= 0; + var rootsText = String(piLabState.allowedRootsSummary || '').toLowerCase(); + var readonly = rootsText.indexOf('read') >= 0 || rootsText.indexOf('只读') >= 0; + if (hasDeny || readonly) return { label: hasAsk ? '受限/审批' : '受限', tone: 'danger' }; + if (hasAsk || !values.length) return { label: '变更前确认', tone: 'warn' }; + return { label: '自动工具', tone: 'ok' }; + } + + function updateThinkingControl() { + if (!piLabPanelEl) return; + var thinkingSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-thinking]'); + var level = normalizeThinkingLevel(piLabState.thinkingLevel || piLabState.defaultThinkingLevel); + piLabState.thinkingLevel = level; + if (thinkingSelect) { + if (thinkingSelect.value !== level) thinkingSelect.value = level; + thinkingSelect.title = '思考深度:' + currentThinkingLabel() + ';启动本次 Pi 会话时应用'; + } + } + + function updatePermissionControl() { + if (!piLabPanelEl) return; + var control = piLabPanelEl.querySelector('[data-page-ai-pi-lab-permission]'); + var label = piLabPanelEl.querySelector('[data-page-ai-pi-lab-permission-label]'); + var wrap = piLabPanelEl.querySelector('[data-page-ai-pi-lab-permission-wrap]'); + var summary = permissionSummary(); + if (label) label.textContent = summary.label; + if (control) { + control.setAttribute('data-tone', summary.tone || ''); + control.setAttribute('data-active', piLabState.permissionMenuOpen ? 'true' : 'false'); + control.title = 'Pi 模式:' + permissionModeLabel(piLabState.permissionMode) + ';目录范围:' + (piLabState.allowedRootsSummary || 'pending'); + } + if (wrap) wrap.setAttribute('data-open', piLabState.permissionMenuOpen ? 'true' : 'false'); + Array.prototype.slice.call(piLabPanelEl.querySelectorAll('[data-page-ai-pi-lab-permission-mode]')).forEach(function (node) { + node.setAttribute('data-active', node.getAttribute('data-page-ai-pi-lab-permission-mode') === normalizePermissionMode(piLabState.permissionMode) ? 'true' : 'false'); + var check = node.querySelector('[data-page-ai-pi-lab-permission-check]'); + if (check) check.textContent = node.getAttribute('data-active') === 'true' ? '✓' : ''; + }); + } + + function setPermissionMenuOpen(open) { + piLabState.permissionMenuOpen = !!open; + updatePermissionControl(); + } + + function selectPermissionMode(mode) { + piLabState.permissionMode = normalizePermissionMode(mode); + setPermissionMenuOpen(false); + updatePermissionControl(); + if (piLabState.sessionId && piLabState.status !== STATE_STREAMING && piLabState.status !== STATE_STARTING) { + return applyPermissionModeToRuntime(); + } + if (piLabState.status === STATE_STREAMING) { + piLabState.pendingPermissionModeApply = true; + showPiToast('当前回复结束后自动应用:' + permissionModeLabel(piLabState.permissionMode), 'warning'); + return Promise.resolve({ queued: true }); + } + showPiToast('Pi 模式已切换为:' + permissionModeLabel(piLabState.permissionMode), 'info'); + return Promise.resolve({ queued: false }); + } + + function maybeApplyPendingPermissionMode() { + if (!piLabState.pendingPermissionModeApply || piLabState.applyingPermissionMode) return; + if (!piLabState.sessionId || !piLabState.enabled) return; + if (piLabState.status === STATE_STREAMING || piLabState.status === STATE_STARTING) return; + window.setTimeout(function () { + if (!piLabState.pendingPermissionModeApply || piLabState.applyingPermissionMode) return; + if (piLabState.status === STATE_STREAMING || piLabState.status === STATE_STARTING) return; + applyPermissionModeToRuntime().catch(function () {}); + }, 0); + } + + function applyPermissionModeToRuntime() { + if (!piLabState.sessionId || !piLabState.enabled) return Promise.resolve({ skipped: true }); + if (piLabState.applyingPermissionMode) { + piLabState.pendingPermissionModeApply = true; + return Promise.resolve({ queued: true }); + } + var context = applyCurrentContextToState(); + var nextMode = normalizePermissionMode(piLabState.permissionMode); + piLabState.pendingPermissionModeApply = false; + piLabState.applyingPermissionMode = true; + setState(STATE_STARTING); + updateDiagnostics('Applying Pi mode'); + return fetch(API.START, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: piLabState.sessionId, + rootUri: context.rootUri || (piLabState.session && piLabState.session.rootUri) || undefined, + workspaceId: context.workspaceId || (piLabState.session && piLabState.session.workspaceId) || undefined, + pagePath: context.pagePath || (piLabState.session && piLabState.session.pagePath) || undefined, + pageTitle: context.pageTitle || (piLabState.session && piLabState.session.pageTitle) || undefined, + modelProvider: piLabState.modelProvider || piLabState.defaultModelProvider, + modelId: piLabState.modelId || piLabState.defaultModelId, + thinkingLevel: normalizeThinkingLevel(piLabState.thinkingLevel || piLabState.defaultThinkingLevel), + permissionMode: nextMode, + }), + }) + .then(function (r) { + if (!r.ok) throw new Error('Mode switch failed: ' + r.status); + return r.json(); + }) + .then(function (data) { + var session = data.session || {}; + piLabState.sessionId = session.sessionId || data.sessionId || piLabState.sessionId; + piLabState.providerSessionId = session.providerSessionId || data.providerSessionId || piLabState.providerSessionId; + piLabState.permissionMode = normalizePermissionMode(session.permissionMode || data.permissionMode || (session.runtimePolicySnapshot && session.runtimePolicySnapshot.permissionMode) || nextMode); + piLabState.session = session || piLabState.session || null; + piLabState.runtimeMode = session.runtimeMode || data.runtimeMode || piLabState.runtimeMode || null; + piLabState.runtimePid = session.runtimePid || data.pid || piLabState.runtimePid || null; + piLabState.piExtensionSources = data.piExtensionSources || piLabState.piExtensionSources || []; + piLabState.piExtensionToolNames = data.piExtensionToolNames || piLabState.piExtensionToolNames || []; + connectEventSource(); + piLabState.applyingPermissionMode = false; + setState(STATE_STARTED); + updateDiagnostics('Pi mode applied'); + updatePermissionControl(); + showPiToast('Pi 模式已应用:' + permissionModeLabel(piLabState.permissionMode), 'success'); + maybeApplyPendingPermissionMode(); + return data; + }) + .catch(function (error) { + piLabState.applyingPermissionMode = false; + setState(STATE_ERROR); + updateDiagnostics(error && error.message ? error.message : 'Pi mode switch failed'); + showPiToast('Pi 模式切换失败', 'error'); + throw error; + }); + } + + function setActionMenuOpen(open) { + piLabState.actionMenuOpen = !!open; + if (!piLabPanelEl) return; + var composer = piLabPanelEl.querySelector('.wolai-page-ai-pi-lab-composer'); + var toggle = piLabPanelEl.querySelector('[data-page-ai-pi-lab-action-menu-toggle]'); + if (composer) composer.setAttribute('data-menu-open', piLabState.actionMenuOpen ? 'true' : 'false'); + if (toggle) toggle.setAttribute('data-active', piLabState.actionMenuOpen ? 'true' : 'false'); + } + + function closeActionMenu() { + setActionMenuOpen(false); + } + + function permissionModeMenuHtml() { + return permissionModeOptions().map(function (item) { + var active = item.mode === normalizePermissionMode(piLabState.permissionMode); + return ''; + }).join(''); + } + + function handleActionMenuAction(action) { + closeActionMenu(); + if (action === 'read-page' || action === 'current-folder' || action === 'selection' || action === 'rag') { + handleQuickAction(action); + return; + } + if (action === 'directory-permission') { + window.location.assign('/user/ai#ai-admin-access'); + return; + } + if (action === 'send-steer') { + sendCurrentInput({ streamingBehavior: 'steer' }); + return; + } + if (action === 'send-followup') { + sendCurrentInput({ streamingBehavior: 'followUp' }); + return; + } + if (action === 'plan-mode') { + var input = piLabPanelEl && piLabPanelEl.querySelector('[data-page-ai-pi-lab-input]'); + var current = input ? input.value.trim() : ''; + piLabState.permissionMode = 'plan'; + updatePermissionControl(); + if (piLabState.sessionId && piLabState.status !== STATE_STREAMING && piLabState.status !== STATE_STARTING) { + applyPermissionModeToRuntime().catch(function () {}); + } else { + piLabState.pendingPermissionModeApply = true; + } + var planPrompt = current || '请先只做计划评审,不要修改文件。'; + if (input) { + input.value = planPrompt; + input.focus(); + updateButtons(); + } + showPiToast('已切换到 MNote 计划评审模式;发送后由后端只读 wrapper 处理', 'info'); + } + } + function applyModelControls() { if (!piLabPanelEl) return; var providerSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-provider]'); var modelSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-id]'); var bottomModelSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-id-bottom]'); + var thinkingSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-thinking]'); var customInput = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-custom]'); if (providerSelect && providerSelect.value) piLabState.modelProvider = providerSelect.value; if (customInput && customInput.value.trim()) piLabState.modelId = customInput.value.trim(); else if (modelSelect && modelSelect.value) piLabState.modelId = modelSelect.value; else if (bottomModelSelect && bottomModelSelect.value) piLabState.modelId = bottomModelSelect.value; + if (thinkingSelect && thinkingSelect.value) piLabState.thinkingLevel = normalizeThinkingLevel(thinkingSelect.value); + piLabState.permissionMode = normalizePermissionMode(piLabState.permissionMode); updateModelLabel(); + updateThinkingControl(); + updatePermissionControl(); } function splitModelRef(value) { @@ -367,6 +1469,8 @@ var defaultRef = splitModelRef(data.defaultModel || data.default_model || ''); piLabState.defaultModelProvider = defaultRef.provider || DEFAULT_MODEL_PROVIDER; piLabState.defaultModelId = defaultRef.modelId || DEFAULT_MODEL_ID; + piLabState.defaultThinkingLevel = normalizeThinkingLevel(data.defaultThinkingLevel || data.default_thinking_level || piLabState.defaultThinkingLevel); + if (!piLabState.thinkingLevel) piLabState.thinkingLevel = piLabState.defaultThinkingLevel; piLabState.modelOptions = Array.isArray(data.models) ? data.models : []; if (Array.isArray(data.allowedRoots)) { piLabState.allowedRootsSummary = data.allowedRoots.length + ' roots'; @@ -400,6 +1504,7 @@ if (modelSelect) modelSelect.innerHTML = optionHtml; if (bottomModelSelect) bottomModelSelect.innerHTML = optionHtml; updateModelLabel(); + updateThinkingControl(); updateContextStrip(); } @@ -439,6 +1544,36 @@ return 'error'; } + function piIcon(name) { + var icons = { + plus: '', + history: '', + external: '', + artifacts: '', + status: '', + bell: '', + play: '', + stop: '', + trash: '', + settings: '', + zap: '', + file: '', + selection: '', + database: '', + shield: '', + brain: '', + folder: '', + image: '', + camera: '', + queue: '', + copy: '', + send: '', + close: '', + minus: '', + }; + return ''; + } + function piLabPanelHTML() { return '' + '
    ' + @@ -447,20 +1582,16 @@ '' + '
    Pi Lab 平台默认模型:omniroute/freefirst
    ' + '
    ' + - '' + - '' + + '' + + '' + '' + '
    ' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + + '' + + '' + '
    ' + '
    ' + '
    ' + @@ -468,17 +1599,21 @@ '
    ' + '' + '' + - '' + + '' + 'runtimeruntime pending' + - 'builtin disabledbash/read/write/edit' + + 'builtin managedread/write/edit/bash' + + 'Pi 扩展pending' + '
    ' + '
    ' + '
    ' + '操作见顶部工具栏' + '
    ' + '
    ' + - '' + '' + - '
    ' + + '
    ' + '
    ' + + '' + '
    ' + '
    ' + - '' + - '
    ' + - '' + - '' + - '' + - '' + - '' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    上下文
    ' + + '' + + '' + + '' + + '' + + '
    ' + + '
    权限与附件
    ' + + '' + + '' + + '' + + '
    ' + + '
    执行中发送
    ' + + '' + + '' + + '' + '
    ' + '
    ' + - '' + - '' + + '' + '' + - '' + - '不限' + - '' + - '' + + '' + + '
    ' + + '' + + '
    ' + permissionModeMenuHtml() + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + '
    ' + '
    ' + '
    ' + '' + '
    ' + '
    ' + @@ -551,21 +1710,76 @@ '
    '; } + function historyTitle(session) { + return (session && (session.title || session.preview || session.pageTitle || session.pagePath)) || '未命名会话'; + } + + function formatHistoryTime(value) { + if (!value) return '刚刚'; + var time = Date.parse(value); + if (!Number.isFinite(time)) return String(value); + var diff = Date.now() - time; + if (diff < 60 * 1000) return '刚刚'; + if (diff < 60 * 60 * 1000) return Math.floor(diff / (60 * 1000)) + ' 分钟前'; + if (diff < 24 * 60 * 60 * 1000) return Math.floor(diff / (60 * 60 * 1000)) + ' 小时前'; + if (diff < 7 * 24 * 60 * 60 * 1000) return Math.floor(diff / (24 * 60 * 60 * 1000)) + ' 天前'; + return new Date(time).toLocaleDateString(); + } + + function historyShell(innerHtml) { + var count = Array.isArray(piLabState.history) ? piLabState.history.length : 0; + return '
    历史对话
    ' + + '
    ' + + '' + + '' + + '' + + '
    ' + + '
    共 ' + count + ' 条历史记录Pi Rust
    ' + + '
    ' + innerHtml + '
    '; + } + function renderHistory() { if (!piLabPanelEl) return; var panel = piLabPanelEl.querySelector('[data-page-ai-pi-lab-history-panel]'); if (!panel) return; if (!piLabState.history.length) { - panel.innerHTML = '
    暂无 Pi Lab 历史
    '; + panel.innerHTML = historyShell('
    暂无历史对话
    '); return; } - panel.innerHTML = piLabState.history.map(function (session) { - var title = escapeHtml(session.title || session.preview || session.pageTitle || '未命名会话'); - var meta = escapeHtml([session.status, session.modelId, session.updatedAt].filter(Boolean).join(' · ')); - return ''; - }).join(''); + panel.innerHTML = historyShell(piLabState.history.map(function (session) { + var title = escapeHtml(historyTitle(session)); + var meta = escapeHtml([formatHistoryTime(session.updatedAt || session.createdAt), session.modelId || session.status].filter(Boolean).join(' · ')); + var sessionId = escapeHtml(session.sessionId || ''); + var active = piLabState.sessionId === (session.sessionId || ''); + return '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    '; + }).join('')); + } + + function setHistoryPanelVisible(visible) { + if (!piLabPanelEl) return Promise.resolve(); + var layer = piLabPanelEl.querySelector('[data-page-ai-pi-lab-history-layer]'); + var panel = piLabPanelEl.querySelector('[data-page-ai-pi-lab-history-panel]'); + var historyBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-history]'); + if (!layer || !panel) return Promise.resolve(); + layer.hidden = !visible; + if (historyBtn) historyBtn.setAttribute('data-active', visible ? 'true' : 'false'); + if (visible) { + var body = piLabPanelEl.querySelector('[data-page-ai-pi-lab-body]'); + if (body) body.setAttribute('data-rail-open', 'false'); + return loadHistory().catch(function (error) { + panel.innerHTML = historyShell('
    ' + escapeHtml(error.message || '历史加载失败') + '
    '); + }); + } + return Promise.resolve(); } function loadHistory() { @@ -583,31 +1797,211 @@ function openHistorySession(sessionId) { if (!sessionId) return Promise.resolve(); + if (piLabEventSource) { + piLabEventSource.close(); + piLabEventSource = null; + } return Promise.all([ fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId), { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { return r.json(); }), fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId) + '/events?limit=500', { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { return r.json(); }), ]).then(function (results) { var detail = results[0] && results[0].session || {}; + var runtime = detail.runtime || {}; + var runtimePolicy = runtime.runtimePolicy || runtime.runtime_policy || {}; var events = results[1] && Array.isArray(results[1].events) ? results[1].events : []; piLabState.sessionId = detail.sessionId || sessionId; - piLabState.session = detail.runtime || detail; - piLabState.messages = []; + piLabState.providerSessionId = runtime.providerSessionId || detail.providerSessionId || null; + piLabState.session = Object.assign({}, runtime, detail, { + sessionId: piLabState.sessionId, + providerSessionId: piLabState.providerSessionId, + pagePath: runtime.pagePath || detail.pagePath, + pageTitle: runtime.pageTitle || detail.pageTitle || detail.title, + rootUri: runtime.rootUri || detail.rootUri, + workspaceId: runtime.workspaceId || detail.workspaceId, + modelProvider: runtime.modelProvider || detail.modelProvider, + modelId: runtime.modelId || detail.modelId, + thinkingLevel: runtime.thinkingLevel || detail.thinkingLevel, + runtimePolicySnapshot: runtimePolicy, + }); + piLabState.modelProvider = piLabState.session.modelProvider || piLabState.modelProvider || null; + piLabState.modelId = piLabState.session.modelId || piLabState.modelId || null; + piLabState.thinkingLevel = normalizeThinkingLevel(piLabState.session.thinkingLevel || piLabState.thinkingLevel || piLabState.defaultThinkingLevel); + piLabState.permissionMode = normalizePermissionMode(runtimePolicy.permissionMode || runtimePolicy.permission_mode || detail.permissionMode || piLabState.permissionMode); + piLabState.allowedRootsSummary = summarizeAllowedRoots(runtime.allowedRootsSnapshot || piLabState.session.allowedRootsSnapshot); + enterHistoryReplayMode(sessionId); + resetPiRunViewModel(); events.forEach(function (event) { var payload = event.payload || {}; if (event.eventType === 'user_prompt' && payload.message) { - piLabState.messages.push({ role: 'user', text: payload.message }); + applyPiRunEvent({ type: 'user_prompt', text: payload.message }); + return; } if (event.eventType === 'pi_rpc_event') { - var delta = payload.assistantMessageEvent && payload.assistantMessageEvent.delta; - if (delta) piLabState.messages.push({ role: 'assistant', text: delta }); + handlePiRpcEvent(payload); + return; + } + if (event.eventType === 'runtime_aborted') { + applyPiRunEvent({ type: 'assistant_abort', payload: payload }); } }); + if (streamingAssistantMsg && String(streamingAssistantMsg.text || '').trim()) { + applyPiRunEvent({ type: 'assistant_done', text: streamingAssistantMsg.text }); + } + setState(STATE_IDLE); + updateDiagnostics('已打开历史 session,可直接继续发送。'); updateMessages(); updateConfig(); + renderHistory(); + setHistoryPanelVisible(false); }); } - function renderMessage(msg) { + function currentHistorySession(sessionId) { + return (piLabState.history || []).find(function (item) { + return item && item.sessionId === sessionId; + }) || null; + } + + function renameHistorySession(sessionId) { + if (!sessionId) return Promise.resolve(); + var existing = currentHistorySession(sessionId); + var nextTitle = window.prompt('重命名 Pi session', (existing && (existing.title || existing.preview || existing.pageTitle)) || ''); + if (nextTitle == null) return Promise.resolve(); + nextTitle = String(nextTitle || '').trim(); + if (!nextTitle) { + showPiToast('标题不能为空', 'warning'); + return Promise.resolve(); + } + return fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId), { + method: 'PATCH', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ + title: nextTitle, + workspaceId: (piLabState.session && piLabState.session.workspaceId) || currentWorkspaceId() || undefined, + }), + }).then(function (response) { + return response.json().catch(function () { return {}; }).then(function (payload) { + if (!response.ok) throw new Error(payload.message || ('Rename failed: ' + response.status)); + showPiToast('Session renamed', 'success'); + return loadHistory(); + }); + }); + } + + function resetCurrentSessionAfterHistoryDelete(sessionId) { + if (piLabState.sessionId !== sessionId && piLabState.viewingHistorySessionId !== sessionId) return; + if (piLabEventSource) { + piLabEventSource.close(); + piLabEventSource = null; + } + exitHistoryReplayMode(); + piLabState.sessionId = null; + piLabState.providerSessionId = null; + piLabState.status = STATE_IDLE; + piLabState.session = null; + resetPiRunViewModel(); + piLabState.receipts = []; + updateMessages(); + updateReceiptDisplay(); + updateQueueDisplay(); + updateConfig(); + } + + function deleteHistorySession(sessionId) { + if (!sessionId) return Promise.resolve(); + var existing = currentHistorySession(sessionId); + var title = existing ? historyTitle(existing) : sessionId; + if (!window.confirm('删除历史对话“' + title + '”?')) return Promise.resolve(); + return fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId), { + method: 'DELETE', + credentials: 'same-origin', + headers: { accept: 'application/json' }, + }).then(function (response) { + return response.json().catch(function () { return {}; }).then(function (payload) { + if (!response.ok) throw new Error(payload.message || ('Delete failed: ' + response.status)); + resetCurrentSessionAfterHistoryDelete(sessionId); + showPiToast('历史对话已删除', 'success'); + return loadHistory(); + }); + }); + } + + function clearHistorySessions() { + var count = Array.isArray(piLabState.history) ? piLabState.history.length : 0; + if (!count) return Promise.resolve(); + if (!window.confirm('清空全部 ' + count + ' 条 Pi 历史对话?')) return Promise.resolve(); + return fetch(API.SESSIONS + '?limit=1000', { + method: 'DELETE', + credentials: 'same-origin', + headers: { accept: 'application/json' }, + }).then(function (response) { + return response.json().catch(function () { return {}; }).then(function (payload) { + if (!response.ok) throw new Error(payload.message || ('Clear failed: ' + response.status)); + resetCurrentSessionAfterHistoryDelete(piLabState.sessionId || piLabState.viewingHistorySessionId); + piLabState.history = []; + renderHistory(); + showPiToast('历史对话已清空', 'success'); + }); + }); + } + + function exportHistorySession(sessionId) { + if (!sessionId) return Promise.resolve(); + return Promise.all([ + fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId), { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { return r.json(); }), + fetch(API.SESSIONS + '/' + encodeURIComponent(sessionId) + '/events?limit=1000', { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { return r.json(); }), + ]).then(function (results) { + var payload = { + schema: 'mnote.page_ai_pi.export_session.v1', + exportedAt: new Date().toISOString(), + session: results[0] && results[0].session || null, + events: results[1] && Array.isArray(results[1].events) ? results[1].events : [], + }; + var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); + var objectUrl = URL.createObjectURL(blob); + var link = document.createElement('a'); + link.href = objectUrl; + link.download = 'pi-session-' + sessionId + '.json'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 1000); + showPiToast('历史对话已导出', 'success'); + }); + } + + function forkHistorySession(sessionId) { + return openHistorySession(sessionId).then(function () { + var oldSessionId = piLabState.sessionId; + var source = piLabState.session || {}; + if (piLabEventSource) { + piLabEventSource.close(); + piLabEventSource = null; + } + piLabState.sessionId = null; + piLabState.providerSessionId = null; + piLabState.status = STATE_IDLE; + exitHistoryReplayMode(); + resetPiRunViewModel(); + piLabState.receipts = []; + piLabState.session = Object.assign({}, source, { + sessionId: null, + providerSessionId: null, + pageTitle: ((source.pageTitle || source.title || 'Pi session') + ' fork').slice(0, 120), + }); + updateMessages(); + updateReceiptDisplay(); + updateConfig(); + updateDiagnostics('Forked from ' + oldSessionId + '; start creates a new Pi runtime'); + showPiToast('Fork prepared', 'success'); + return startRuntime(); + }).then(function (data) { + return loadHistory().then(function () { return data; }).catch(function () { return data; }); + }); + } + + function renderMessage(msg) { var card = el('article', { className: 'wolai-page-ai-pi-lab-message', 'data-role': msg.role || 'assistant', @@ -615,24 +2009,31 @@ }); if (msg.status === 'streaming') card.setAttribute('data-page-ai-pi-lab-streaming', 'true'); var role = msg.role === 'assistant' ? 'Pi' : (msg.role === 'user' ? 'You' : 'System'); - var meta = msg.status === 'streaming' ? 'streaming' : (msg.meta || ''); + var meta = msg.status === 'streaming' ? 'streaming' : (msg.status === 'aborted' ? 'aborted' : (msg.meta || '')); card.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-role' }, role + (meta ? ' · ' + meta : ''))); var bubble = el('div', { className: 'wolai-page-ai-pi-lab-bubble' }); if (msg.text) { + var rendersMarkdown = msg.role === 'assistant' || msg.role === 'system'; bubble.appendChild(el('div', { - className: 'wolai-page-ai-pi-lab-text' + (msg.status === 'streaming' ? ' streaming-cursor' : ''), - innerHTML: escapeHtml(msg.text).replace(/\n/g, '
    '), + className: (rendersMarkdown ? 'wolai-page-ai-pi-lab-markdown' : 'wolai-page-ai-pi-lab-text') + (msg.status === 'streaming' ? ' streaming-cursor' : ''), + innerHTML: rendersMarkdown + ? renderMarkdownBlock(msg.text) + : escapeHtml(msg.text).replace(/\n/g, '
    '), })); } if (msg.toolCalls && msg.toolCalls.length) { - var tools = el('div', { className: 'wolai-page-ai-pi-lab-tool-calls' }); - msg.toolCalls.forEach(function (tc) { - var item = el('div', { className: 'wolai-page-ai-pi-lab-tool-call', 'data-page-ai-pi-lab-tool-call': tc.name || 'tool' }); - item.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-tool-call-header', innerHTML: '' + escapeHtml(tc.name || 'tool') + '' + escapeHtml(tc.status || '') + '' })); - if (tc.args) item.appendChild(el('pre', { textContent: JSON.stringify(tc.args, null, 2) })); - if (tc.result) item.appendChild(el('pre', { textContent: typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result, null, 2) })); - tools.appendChild(item); + var tools = el('details', { + className: 'wolai-page-ai-pi-lab-tool-timeline', + 'data-page-ai-pi-lab-tool-timeline': 'true', + 'data-page-ai-pi-tool-timeline': 'true', }); + tools.appendChild(el('summary', {}, [ + el('span', {}, '工具调用'), + el('span', { className: 'wolai-page-ai-pi-lab-rail-count' }, String(msg.toolCalls.length)), + ])); + var toolsBody = el('div', { className: 'wolai-page-ai-pi-lab-tool-timeline-body' }); + msg.toolCalls.forEach(function (tc) { toolsBody.appendChild(renderToolCallCard(tc)); }); + tools.appendChild(toolsBody); bubble.appendChild(tools); } if (msg.citations && msg.citations.length) { @@ -648,17 +2049,46 @@ }); bubble.appendChild(citations); } - if (msg.diffSummary) { - var files = msg.diffSummary.files || []; - bubble.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-diff', 'data-page-ai-pi-lab-diff': 'true' }, 'patch · ' + files.join(', '))); - } + if (msg.diffSummary) { + var files = msg.diffSummary.files || []; + bubble.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-diff', 'data-page-ai-pi-lab-diff': 'true' }, 'patch · ' + files.join(', '))); + } + if (msg.status === 'aborted' || msg.abortReason) { + var abortPayload = msg.abortResponse || {}; + var abortLines = [msg.abortReason || '已中止']; + if (abortPayload.schema) abortLines.push('schema=' + abortPayload.schema); + if (abortPayload.stopReason) abortLines.push('stopReason=' + abortPayload.stopReason); + if (abortPayload.command) abortLines.push('command=' + abortPayload.command); + var queue = piLabState.pendingQueue || {}; + var steering = Array.isArray(queue.steering) ? queue.steering.length : 0; + var followUp = Array.isArray(queue.followUp) ? queue.followUp.length : 0; + abortLines.push('queued messages=' + (steering + followUp)); + bubble.appendChild(el('div', { + className: 'wolai-page-ai-pi-lab-abort-note', + 'data-page-ai-pi-lab-abort-note': 'true', + 'data-page-ai-pi-lab-stop-reason': abortPayload.stopReason || 'aborted', + }, abortLines.join(' · '))); + } + if (msg.role === 'assistant' && (msg.text || msg.toolCalls || msg.citations || msg.diffSummary)) { + bubble.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-message-actions' }, [ + el('button', { + type: 'button', + title: '复制 Markdown', + 'aria-label': '复制 Markdown', + 'data-page-ai-pi-lab-copy-markdown': messageToMarkdown(msg), + innerHTML: piIcon('copy'), + }), + ])); + } card.appendChild(bubble); return card; } function isVisibleMessage(msg) { if (!msg) return false; + if (msg.role === 'system' && /^Pi session started:/.test(String(msg.text || ''))) return false; if (msg.status === 'streaming') return true; + if (msg.status === 'aborted' || msg.abortReason) return true; if (String(msg.text || '').trim()) return true; if (msg.toolCalls && msg.toolCalls.length) return true; if (msg.citations && msg.citations.length) return true; @@ -677,6 +2107,7 @@ var providerSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-provider]'); var modelSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-id]'); var bottomModelSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-id-bottom]'); + var thinkingSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-thinking]'); var customInput = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-custom]'); var provider = piLabState.modelProvider || piLabState.defaultModelProvider || DEFAULT_MODEL_PROVIDER; var modelId = piLabState.modelId || piLabState.defaultModelId || DEFAULT_MODEL_ID; @@ -699,10 +2130,13 @@ function updateConfig() { if (!piLabPanelEl) return; updateModelLabel(); + updateThinkingControl(); var statusChip = piLabPanelEl.querySelector('[data-page-ai-pi-lab-status-chip]'); var statusNode = piLabPanelEl.querySelector('[data-page-ai-pi-lab-status-text]'); var runtimeNode = piLabPanelEl.querySelector('[data-page-ai-pi-lab-runtime-chip]'); - var builtinNode = piLabPanelEl.querySelector('[data-page-ai-pi-lab-builtin-disabled]'); + var builtinNode = piLabPanelEl.querySelector('[data-page-ai-pi-lab-builtin-managed]'); + var extensionCountNode = piLabPanelEl.querySelector('[data-page-ai-pi-lab-extension-count]'); + var replayBanner = piLabPanelEl.querySelector('[data-page-ai-pi-lab-replay-banner]'); if (statusChip) { var tone = statusTone(); if (tone) statusChip.setAttribute('data-tone', tone); @@ -710,9 +2144,26 @@ } if (statusNode) statusNode.textContent = statusText(); if (runtimeNode) runtimeNode.textContent = currentRuntimeLabel(); - if (builtinNode) builtinNode.style.display = piLabState.disabledBuiltinTools ? '' : 'none'; + if (builtinNode) { + var managed = Array.isArray(piLabState.managedBuiltinTools) ? piLabState.managedBuiltinTools : []; + builtinNode.style.display = managed.length ? '' : 'none'; + var strong = builtinNode.querySelector('strong'); + if (strong) strong.textContent = managed.length ? managed.join('/') : 'pending'; + } + if (extensionCountNode) { + var extensions = Array.isArray(piLabState.piExtensionSources) ? piLabState.piExtensionSources : []; + extensionCountNode.textContent = extensions.length ? String(extensions.length) + ' packages' : 'pending'; + extensionCountNode.title = extensions.join('\n'); + } + if (replayBanner) { + replayBanner.hidden = !piLabState.viewingHistorySessionId; + var replayText = replayBanner.querySelector('span'); + if (replayText) replayText.textContent = piLabState.viewingHistorySessionId ? '正在查看历史,只读' : ''; + } updateContextStrip(); + updatePermissionControl(); updateButtons(); + updateQueueDisplay(); updateLauncherState(); } @@ -738,34 +2189,78 @@ }); changedFiles.textContent = String(Object.keys(files).length); } + var selected = contextSelectionState(); + Array.prototype.slice.call(piLabPanelEl.querySelectorAll('[data-page-ai-pi-lab-quick]')).forEach(function (btn) { + var kind = btn.getAttribute('data-page-ai-pi-lab-quick') || ''; + var active = (kind === 'read-page' && selected.currentPage) + || (kind === 'current-folder' && selected.currentFolder) + || (kind === 'selection' && selected.selection) + || (kind === 'rag' && selected.lightrag); + btn.setAttribute('data-active', active ? 'true' : 'false'); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + Array.prototype.slice.call(piLabPanelEl.querySelectorAll('[data-page-ai-pi-lab-menu-action]')).forEach(function (btn) { + var action = btn.getAttribute('data-page-ai-pi-lab-menu-action') || ''; + var active = (action === 'read-page' && selected.currentPage) + || (action === 'current-folder' && selected.currentFolder) + || (action === 'selection' && selected.selection) + || (action === 'rag' && selected.lightrag); + if (['read-page', 'current-folder', 'selection', 'rag'].indexOf(action) >= 0) { + btn.setAttribute('data-active', active ? 'true' : 'false'); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + } + }); + Array.prototype.slice.call(piLabPanelEl.querySelectorAll('[data-page-ai-pi-lab-context-mark]')).forEach(function (mark) { + var action = mark.getAttribute('data-page-ai-pi-lab-context-mark') || ''; + var active = (action === 'read-page' && selected.currentPage) + || (action === 'current-folder' && selected.currentFolder) + || (action === 'selection' && selected.selection) + || (action === 'rag' && selected.lightrag); + mark.textContent = active ? '✓' : ''; + }); + updatePermissionControl(); } function updateButtons() { if (!piLabPanelEl) return; - var startBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-btn-start]'); var sendBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-btn-send]'); var abortBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-btn-abort]'); var input = piLabPanelEl.querySelector('[data-page-ai-pi-lab-input]'); var providerSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-provider]'); var modelSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-id]'); var bottomModelSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-id-bottom]'); + var thinkingSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-thinking]'); var customInput = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-custom]'); + var isReplay = !!piLabState.viewingHistorySessionId; var isIdle = piLabState.status === STATE_IDLE || piLabState.status === STATE_ABORTED || piLabState.status === STATE_ERROR; var isStarted = piLabState.status === STATE_STARTED; - var isStreaming = piLabState.status === STATE_STREAMING || piLabState.status === STATE_STARTING; - if (startBtn) { - startBtn.hidden = !isIdle; - startBtn.disabled = !piLabState.enabled || piLabState.status === STATE_STARTING; - } + var isStreaming = piLabState.status === STATE_STREAMING; if (sendBtn) { - sendBtn.disabled = !piLabState.enabled || !(isStarted || piLabState.status === STATE_IDLE || piLabState.status === STATE_ABORTED || piLabState.status === STATE_ERROR) || !input || !input.value.trim(); + sendBtn.disabled = isReplay || !piLabState.enabled || !input || !input.value.trim(); } - if (abortBtn) abortBtn.hidden = !isStreaming; - [providerSelect, modelSelect, bottomModelSelect, customInput].forEach(function (node) { - if (node) node.disabled = !isIdle; + if (abortBtn) abortBtn.hidden = !(isStreaming || piLabState.status === STATE_STARTING); + if (input) { + input.disabled = isReplay; + input.placeholder = isReplay ? '历史对话为只读,Fork 后继续' : '问 Pi...'; + } + [providerSelect, modelSelect, bottomModelSelect, thinkingSelect, customInput].forEach(function (node) { + if (node) node.disabled = isReplay || !isIdle; }); } + function updateQueueDisplay() { + if (!piLabPanelEl) return; + var queue = piLabState.pendingQueue || {}; + var steering = Array.isArray(queue.steering) ? queue.steering.length : 0; + var followUp = Array.isArray(queue.followUp) ? queue.followUp.length : 0; + var count = steering + followUp; + var node = piLabPanelEl.querySelector('[data-page-ai-pi-lab-queue-count]'); + if (node) { + node.textContent = String(count); + node.title = '排队消息 ' + count; + } + } + function updateMessages() { var container = piLabPanelEl && piLabPanelEl.querySelector('[data-page-ai-pi-lab-messages]'); if (!container) return; @@ -773,11 +2268,14 @@ var visibleMessages = piLabState.messages.filter(isVisibleMessage); if (!visibleMessages.length) { renderEmptyState(container); + updateArtifactRail(); return; } visibleMessages.forEach(function (msg) { container.appendChild(renderMessage(msg)); }); container.scrollTop = container.scrollHeight; updateContextStrip(); + updateQueueDisplay(); + updateArtifactRail(); } function updateDiagnostics(text) { @@ -790,13 +2288,481 @@ lines.push('providerSession=' + (piLabState.providerSessionId || 'none')); lines.push('model=' + currentModelLabel()); lines.push('runtime=' + currentRuntimeLabel()); - lines.push('disabledBuiltinTools=' + (piLabState.disabledBuiltinTools ? 'bash/read/write/edit' : 'pending')); + lines.push('managedBuiltinTools=' + ((Array.isArray(piLabState.managedBuiltinTools) && piLabState.managedBuiltinTools.length) ? piLabState.managedBuiltinTools.join('/') : 'pending')); + if (Array.isArray(piLabState.piExtensionSources) && piLabState.piExtensionSources.length) { + lines.push('piExtensions=' + piLabState.piExtensionSources.join(',')); + } if (piLabState.allowedRootsSummary) lines.push('allowedRoots=' + piLabState.allowedRootsSummary); if (piLabState.diagnostics) lines.push('message=' + piLabState.diagnostics); pre.textContent = lines.join('\n'); } - function updateBuiltinDisabled() { + function updatePiFloatingLayerPosition(layer) { + if (!layer || !piLabPanelEl) return; + var composer = piLabPanelEl.querySelector('.wolai-page-ai-pi-lab-composer'); + if (!composer) return; + layer.style.bottom = Math.max(0, Math.ceil(composer.offsetHeight) + 6) + 'px'; + } + + function ensureToastStack() { + var stack = document.querySelector('[data-page-ai-pi-lab-toast-stack]'); + if (stack) { + updatePiFloatingLayerPosition(stack); + return stack; + } + var host = piLabPanelEl && piLabPanelEl.querySelector('.wolai-page-ai-pi-lab-main'); + stack = el('div', { + className: 'wolai-page-ai-pi-lab-toast-stack', + 'data-page-ai-pi-lab-toast-stack': 'true', + }); + (host || document.body).appendChild(stack); + updatePiFloatingLayerPosition(stack); + return stack; + } + + function showPiToast(message, type) { + var stack = ensureToastStack(); + var toast = el('div', { + className: 'wolai-page-ai-pi-lab-toast', + 'data-page-ai-pi-lab-toast': String(++piToastSeq), + 'data-type': type || 'info', + textContent: message || '', + }); + stack.appendChild(toast); + setTimeout(function () { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }, 4200); + return toast; + } + + function closePiUiDialog() { + if (activePiUiDialog && activePiUiDialog.parentNode) activePiUiDialog.parentNode.removeChild(activePiUiDialog); + activePiUiDialog = null; + } + + function closePiCustomUiDialog() { + if (piCustomUiState.panel && piCustomUiState.panel.parentNode) piCustomUiState.panel.parentNode.removeChild(piCustomUiState.panel); + piCustomUiState.panel = null; + piCustomUiState.lines = []; + piCustomUiState.title = ''; + piCustomUiState.selectedOptionIndex = 0; + piCustomUiState.submitted = false; + piCustomUiState.pendingCustomRequest = null; + } + + function sendPiUiResponse(request, response) { + if (!request || !request.id || !piLabState.sessionId) return Promise.resolve(null); + var body = { + sessionId: piLabState.sessionId, + requestId: request.id, + method: request.method || '', + }; + if (request.mnoteApproval) body.mnoteApproval = request.mnoteApproval; + Object.keys(response || {}).forEach(function (key) { body[key] = response[key]; }); + return fetch(API.UI_RESPONSE, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(function (error) { + updateDiagnostics('Extension UI response failed: ' + (error && error.message ? error.message : String(error))); + }); + } + + function stripPiAnsi(text) { + return String(text == null ? '' : text) + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[@-Z\\-_]/g, ''); + } + + function piCustomUiWidth() { + var panel = piCustomUiState.panel; + var body = panel && panel.querySelector('.wolai-page-ai-pi-lab-ui-dialog-body'); + var px = (body && body.clientWidth) || (panel && panel.clientWidth) || 640; + return Math.max(40, Math.min(120, Math.floor(px / 8))); + } + + function parsePiCustomUiOptions(lines) { + var options = []; + (Array.isArray(lines) ? lines : []).forEach(function (line) { + var clean = stripPiAnsi(line).replace(/[│┃║]/g, '').trim(); + var match = clean.match(/^(?:>\s*)?(\d+)\.\s+(.+?)\s*(?:[✎*])?$/); + if (!match) return; + var label = match[2].trim(); + if (!label) return; + options.push({ index: Number(match[1]) - 1, label: label }); + }); + return options; + } + + function renderPiCustomUiDialog(request) { + request = request || {}; + var widgetKey = String(request.widgetKey || request.widget_key || piCustomUiState.widgetKey || '__pi_custom_overlay'); + if (request.clear || request.close) { + closePiCustomUiDialog(); + return null; + } + if (piCustomUiState.submitted) return null; + var lines = Array.isArray(request.lines) + ? request.lines + : (Array.isArray(request.widgetLines) ? request.widgetLines : String(request.content || '').split('\n')); + piCustomUiState.widgetKey = widgetKey; + piCustomUiState.lines = lines.map(stripPiAnsi); + piCustomUiState.title = String(request.title || piCustomUiState.title || 'Pi 需要输入').trim(); + var options = parsePiCustomUiOptions(piCustomUiState.lines); + if (piCustomUiState.selectedOptionIndex >= options.length) piCustomUiState.selectedOptionIndex = 0; + + var panel = piCustomUiState.panel; + if (!panel) { + panel = el('div', { + className: 'wolai-page-ai-pi-lab-ui-backdrop', + 'data-page-ai-pi-lab-ui-dialog': 'custom', + 'data-page-ai-pi-lab-ui-widget-key': widgetKey, + }); + var composer = piLabPanelEl && piLabPanelEl.querySelector('.wolai-page-ai-pi-lab-composer'); + if (composer) composer.insertBefore(panel, composer.firstChild); + else document.body.appendChild(panel); + piCustomUiState.panel = panel; + } + panel.setAttribute('data-page-ai-pi-lab-ui-widget-key', widgetKey); + panel.innerHTML = ''; + var dialog = el('section', { className: 'wolai-page-ai-pi-lab-ui-dialog', role: 'dialog', 'aria-modal': 'false' }); + dialog.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-ui-dialog-header' }, [ + el('div', {}, [ + el('strong', {}, piCustomUiState.title || 'Pi 需要输入'), + el('span', {}, '官方 Pi Rust custom UI'), + ]), + el('button', { type: 'button', className: 'wolai-page-ai-pi-lab-ui-collapse', 'data-page-ai-pi-lab-ui-collapse': 'true', title: '折叠/展开' }, '▾'), + ])); + var body = el('div', { className: 'wolai-page-ai-pi-lab-ui-dialog-body' }); + body.appendChild(el('pre', { className: 'wolai-page-ai-pi-lab-ui-tui' }, piCustomUiState.lines.join('\n'))); + if (options.length) { + var optionList = el('div', { className: 'wolai-page-ai-pi-lab-ui-tui-options' }); + options.forEach(function (option, visibleIndex) { + var selected = visibleIndex === piCustomUiState.selectedOptionIndex; + optionList.appendChild(el('button', { + type: 'button', + className: 'wolai-page-ai-pi-lab-ui-option', + 'data-page-ai-pi-lab-ui-option': option.label, + 'data-custom-option-index': String(visibleIndex), + 'data-custom-selected': selected ? 'true' : 'false', + }, [ + el('span', { className: 'wolai-page-ai-pi-lab-ui-option-mark' }, selected ? '◉' : '○'), + el('span', {}, option.label), + ])); + }); + body.appendChild(optionList); + } + dialog.appendChild(body); + var actions = el('div', { className: 'wolai-page-ai-pi-lab-ui-dialog-actions' }); + actions.appendChild(el('button', { type: 'button', 'data-page-ai-pi-lab-ui-cancel': 'true' }, '取消')); + actions.appendChild(el('button', { type: 'button', 'data-page-ai-pi-lab-ui-submit': 'true', 'data-primary': 'true' }, '提交')); + dialog.appendChild(actions); + panel.appendChild(dialog); + panel.onclick = function (event) { + if (event.target && event.target.closest && event.target.closest('[data-page-ai-pi-lab-ui-collapse]')) { + var collapsed = dialog.getAttribute('data-collapsed') === 'true'; + dialog.setAttribute('data-collapsed', collapsed ? 'false' : 'true'); + event.target.textContent = collapsed ? '▾' : '▸'; + return; + } + var option = event.target && event.target.closest ? event.target.closest('[data-custom-option-index]') : null; + if (option) { + piCustomUiState.selectedOptionIndex = Number(option.getAttribute('data-custom-option-index')) || 0; + renderPiCustomUiDialog(request); + return; + } + if (event.target && event.target.closest && event.target.closest('[data-page-ai-pi-lab-ui-cancel]')) { + var cancelRequest = piCustomUiState.pendingCustomRequest; + piCustomUiState.pendingCustomRequest = null; + piCustomUiState.submitted = true; + if (panel.parentNode) panel.parentNode.removeChild(panel); + piCustomUiState.panel = null; + if (cancelRequest) sendPiUiResponse(cancelRequest, { value: { width: piCustomUiWidth(), key: '\x1b' } }); + else piCustomUiState.keyQueue.push('\x1b'); + return; + } + if (event.target && event.target.closest && event.target.closest('[data-page-ai-pi-lab-ui-submit]')) { + var selectedIndex = Math.max(0, piCustomUiState.selectedOptionIndex || 0); + var submitKeys = []; + for (var i = 0; i < selectedIndex; i += 1) submitKeys.push('\x1b[B'); + submitKeys.push('\r'); + var submitRequest = piCustomUiState.pendingCustomRequest; + piCustomUiState.pendingCustomRequest = null; + piCustomUiState.submitted = true; + if (panel.parentNode) panel.parentNode.removeChild(panel); + piCustomUiState.panel = null; + if (submitRequest) { + var firstKey = submitKeys.shift(); + sendPiUiResponse(submitRequest, { value: { width: piCustomUiWidth(), key: firstKey } }); + } + submitKeys.forEach(function (key) { piCustomUiState.keyQueue.push(key); }); + } + }; + return panel; + } + + function respondToPiCustomUiPoll(request) { + var value = { width: piCustomUiWidth() }; + var key = piCustomUiState.keyQueue.shift(); + if (key) value.key = key; + return sendPiUiResponse(request, { value: value }); + } + + function normalizePiAskQuestions(request) { + var source = Array.isArray(request.questions) && request.questions.length ? request.questions : [{ + header: request.header || request.question || request.title || 'Pi 需要选择', + tab: request.tab || 'answer', + prompt: request.prompt || request.message || request.context || '', + options: request.options || [], + multiSelect: request.allowMultiple === true || request.multiSelect === true, + allowSkip: request.allowSkip !== false, + allowFreeform: request.allowFreeform !== false, + }]; + return source.map(function (question, index) { + return { + header: String(question.header || question.title || question.question || ('问题 ' + (index + 1))).trim(), + tab: String(question.tab || question.id || ('q' + (index + 1))).trim(), + prompt: String(question.prompt || question.description || '').trim(), + options: Array.isArray(question.options) ? question.options : [], + multiSelect: question.multiSelect === true || question.allowMultiple === true, + allowSkip: question.allowSkip !== false, + allowFreeform: question.allowFreeform !== false, + }; + }); + } + + function renderPiAskQuestion(body, question, index) { + var section = el('div', { className: 'wolai-page-ai-pi-lab-ui-question', 'data-page-ai-pi-lab-ui-question': String(index), 'data-tab': question.tab }); + section.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-ui-question-title' }, question.header)); + if (question.prompt) section.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-ui-question-prompt' }, question.prompt)); + question.options.forEach(function (option, optionIndex) { + var label = typeof option === 'string' ? option : String((option && (option.label || option.title || option.value)) || ''); + var description = typeof option === 'string' ? '' : String((option && option.description) || ''); + var value = typeof option === 'string' ? option : String((option && (option.value || option.label || option.title)) || label); + var row = el('button', { + type: 'button', + className: 'wolai-page-ai-pi-lab-ui-option', + 'data-page-ai-pi-lab-ui-option': value, + 'data-question-index': String(index), + 'data-option-index': String(optionIndex), + 'data-selected': 'false', + }, [ + el('span', { className: 'wolai-page-ai-pi-lab-ui-option-mark' }, question.multiSelect ? '□' : '○'), + el('span', {}, [ + el('span', {}, label || value), + description ? el('small', {}, description) : null, + ]), + ]); + section.appendChild(row); + }); + if (question.allowFreeform) { + section.appendChild(el('textarea', { + 'data-page-ai-pi-lab-ui-custom': String(index), + placeholder: 'Type something...', + })); + } + body.appendChild(section); + } + + function collectPiAskResponse(panel, questions) { + var answers = questions.map(function (question, index) { + var section = panel.querySelector('[data-page-ai-pi-lab-ui-question="' + index + '"]'); + var selected = Array.from(section ? section.querySelectorAll('[data-page-ai-pi-lab-ui-option][data-selected="true"]') : []) + .map(function (node) { return node.getAttribute('data-page-ai-pi-lab-ui-option') || ''; }) + .filter(Boolean); + var customNode = section ? section.querySelector('[data-page-ai-pi-lab-ui-custom="' + index + '"]') : null; + var custom = customNode ? customNode.value.trim() : ''; + var answer = { tab: question.tab }; + if (question.multiSelect) { + if (selected.length) answer.answers = selected; + if (custom) answer.custom = custom; + if (!selected.length && !custom) answer.answers = []; + } else if (selected[0]) { + answer.answer = selected[0]; + } else if (custom) { + answer.custom = custom; + } else { + answer.skipped = true; + } + return answer; + }); + var noteNode = panel.querySelector('[data-page-ai-pi-lab-ui-note]'); + var value = { cancelled: false, answers: answers }; + if (noteNode && noteNode.value.trim()) value.message = noteNode.value.trim(); + return { value: value }; + } + + function showPiUiDialog(request) { + closePiUiDialog(); + request = request || {}; + var method = String(request.method || '').trim(); + var isAskUser = method === 'ask_user' || method === 'ask-user' || method === 'questionnaire' || Array.isArray(request.questions); + var title = String(request.title || request.question || (method === 'confirm' ? '确认操作' : 'Pi 需要输入')).trim(); + var message = String(request.message || request.context || '').trim(); + var panel = el('div', { + className: 'wolai-page-ai-pi-lab-ui-backdrop', + 'data-page-ai-pi-lab-ui-dialog': method || 'dialog', + }); + var dialog = el('section', { className: 'wolai-page-ai-pi-lab-ui-dialog', role: 'dialog', 'aria-modal': 'false' }); + dialog.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-ui-dialog-header' }, [ + el('div', {}, [ + el('strong', {}, title), + el('span', {}, message || (method === 'select' ? '请选择一个选项' : '')), + ]), + el('button', { type: 'button', className: 'wolai-page-ai-pi-lab-ui-collapse', 'data-page-ai-pi-lab-ui-collapse': 'true', title: '折叠/展开' }, '▾'), + ])); + var body = el('div', { className: 'wolai-page-ai-pi-lab-ui-dialog-body' }); + var inputNode = null; + var askQuestions = []; + var finish = function (response) { + closePiUiDialog(); + sendPiUiResponse(request, response || {}); + }; + if (isAskUser) { + askQuestions = normalizePiAskQuestions(request); + askQuestions.forEach(function (question, index) { renderPiAskQuestion(body, question, index); }); + body.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-ui-note' }, [ + el('label', {}, 'Note to assistant'), + el('textarea', { 'data-page-ai-pi-lab-ui-note': 'true', placeholder: '可选补充说明' }), + ])); + } else if (method === 'select') { + (Array.isArray(request.options) ? request.options : []).forEach(function (option) { + var value = typeof option === 'string' ? option : String((option && (option.value || option.label)) || ''); + var label = typeof option === 'string' ? option : String((option && (option.label || option.value)) || ''); + body.appendChild(el('button', { + type: 'button', + className: 'wolai-page-ai-pi-lab-ui-option', + 'data-page-ai-pi-lab-ui-option': value, + textContent: label || value, + })); + }); + } else if (method === 'input') { + inputNode = el('input', { + 'data-page-ai-pi-lab-ui-input': 'true', + placeholder: request.placeholder || '', + value: request.prefill || '', + }); + body.appendChild(inputNode); + } else if (method === 'editor') { + inputNode = el('textarea', { + 'data-page-ai-pi-lab-ui-input': 'true', + placeholder: request.placeholder || '', + textContent: request.prefill || '', + }); + body.appendChild(inputNode); + } + if (!body.childNodes.length && method !== 'confirm') { + body.appendChild(el('div', { textContent: message || 'Pi extension UI request' })); + } + dialog.appendChild(body); + var actions = el('div', { className: 'wolai-page-ai-pi-lab-ui-dialog-actions' }); + actions.appendChild(el('button', { type: 'button', 'data-page-ai-pi-lab-ui-cancel': 'true' }, '取消')); + if (method !== 'select') actions.appendChild(el('button', { type: 'button', 'data-page-ai-pi-lab-ui-submit': 'true', 'data-primary': 'true' }, method === 'confirm' ? '确认' : '提交')); + dialog.appendChild(actions); + panel.appendChild(dialog); + var composer = piLabPanelEl && piLabPanelEl.querySelector('.wolai-page-ai-pi-lab-composer'); + if (composer) composer.insertBefore(panel, composer.firstChild); + else document.body.appendChild(panel); + activePiUiDialog = panel; + var timeout = Number(request.timeout || request.timeoutMs || 0); + var timer = timeout > 0 ? setTimeout(function () { finish({ cancelled: true }); }, timeout) : null; + var finishOnce = function (response) { + if (timer) clearTimeout(timer); + finish(response); + }; + panel.addEventListener('click', function (event) { + if (event.target && event.target.closest && event.target.closest('[data-page-ai-pi-lab-ui-collapse]')) { + var collapsed = dialog.getAttribute('data-collapsed') === 'true'; + dialog.setAttribute('data-collapsed', collapsed ? 'false' : 'true'); + event.target.textContent = collapsed ? '▾' : '▸'; + return; + } + var option = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-ui-option]') : null; + if (option && isAskUser) { + var questionIndex = option.getAttribute('data-question-index'); + var question = askQuestions[Number(questionIndex)]; + if (question && question.multiSelect) { + var nextSelected = option.getAttribute('data-selected') !== 'true'; + option.setAttribute('data-selected', nextSelected ? 'true' : 'false'); + var mark = option.querySelector('.wolai-page-ai-pi-lab-ui-option-mark'); + if (mark) mark.textContent = nextSelected ? '▣' : '□'; + } else { + panel.querySelectorAll('[data-question-index="' + questionIndex + '"][data-selected="true"]').forEach(function (node) { + node.setAttribute('data-selected', 'false'); + var oldMark = node.querySelector('.wolai-page-ai-pi-lab-ui-option-mark'); + if (oldMark) oldMark.textContent = '○'; + }); + option.setAttribute('data-selected', 'true'); + var selectedMark = option.querySelector('.wolai-page-ai-pi-lab-ui-option-mark'); + if (selectedMark) selectedMark.textContent = '◉'; + } + return; + } + if (option) { + finishOnce({ value: option.getAttribute('data-page-ai-pi-lab-ui-option') || option.textContent || '' }); + return; + } + if (event.target && event.target.closest && event.target.closest('[data-page-ai-pi-lab-ui-cancel]')) { + finishOnce(isAskUser ? { value: { cancelled: true, answers: [] }, cancelled: true } : { cancelled: true }); + return; + } + if (event.target && event.target.closest && event.target.closest('[data-page-ai-pi-lab-ui-submit]')) { + if (method === 'confirm') finishOnce({ confirmed: true }); + else if (isAskUser) finishOnce(collectPiAskResponse(panel, askQuestions)); + else finishOnce({ value: inputNode ? inputNode.value : '' }); + } + }); + panel.addEventListener('keydown', function (event) { + if (event.key === 'Escape') { + event.preventDefault(); + finishOnce(isAskUser ? { value: { cancelled: true, answers: [] }, cancelled: true } : { cancelled: true }); + } + if (event.key === 'Enter' && method === 'input') { + event.preventDefault(); + finishOnce({ value: inputNode ? inputNode.value : '' }); + } + }); + setTimeout(function () { + var focusTarget = inputNode || panel.querySelector('[data-page-ai-pi-lab-ui-option], [data-page-ai-pi-lab-ui-submit]'); + if (focusTarget && typeof focusTarget.focus === 'function') focusTarget.focus(); + }, 20); + return panel; + } + + function handlePiExtensionUiRequest(payload) { + if (!payload || payload.type !== 'extension_ui_request') return false; + if (payload.method === 'notify') { + showPiToast(payload.message || payload.title || 'Pi notification', payload.notifyType || 'info'); + return true; + } + if (payload.method === 'setWidget' || payload.method === 'set_widget') { + renderPiCustomUiDialog(payload); + return true; + } + if (payload.method === 'custom') { + if (payload.close || payload.mode === 'close') { + closePiCustomUiDialog(); + sendPiUiResponse(payload, { value: { closed: true } }); + } else { + if (Array.isArray(payload.lines) || Array.isArray(payload.widgetLines) || payload.content) renderPiCustomUiDialog(payload); + if (piCustomUiState.keyQueue.length > 0 || piCustomUiState.submitted || !piCustomUiState.panel) { + respondToPiCustomUiPoll(payload); + } else { + piCustomUiState.pendingCustomRequest = payload; + } + } + return true; + } + if (payload.method === 'confirm' || payload.method === 'select' || payload.method === 'input' || payload.method === 'editor' || payload.method === 'ask_user' || payload.method === 'ask-user' || payload.method === 'questionnaire' || Array.isArray(payload.questions)) { + showPiUiDialog(payload); + return true; + } + return false; + } + + function updateBuiltinManaged() { updateConfig(); } @@ -812,8 +2778,9 @@ return; } piLabState.receipts.slice(-8).reverse().forEach(function (receipt) { - var label = (receipt.toolName || 'tool') + ' · ' + (receipt.allowed === false ? 'denied' : 'allowed'); - if (receipt.denyReason) label += ' · ' + receipt.denyReason; + var label = (receipt.toolName || 'tool') + ' · ' + (receipt.allowed === false ? 'denied' : 'allowed'); + if (receipt.approvalRequired) label += receipt.approvalConfirmed ? ' · approved' : ' · approval required'; + if (receipt.denyReason) label += ' · ' + receipt.denyReason; if (receipt.diffSummary) label += ' · diff ' + receipt.diffSummary; if (receipt.citationCount) label += ' · citations ' + receipt.citationCount; if (receipt.afterFileVersion) label += ' · v ' + String(receipt.afterFileVersion).slice(0, 8); @@ -824,9 +2791,123 @@ }); } + function collectPiArtifacts() { + var artifacts = []; + var seen = {}; + piLabState.messages.forEach(function (msg) { + if (!msg) return; + (Array.isArray(msg.citations) ? msg.citations : []).forEach(function (cit, index) { + var key = 'citation:' + (cit.url || cit.source || cit.title || index); + if (seen[key]) return; + seen[key] = true; + artifacts.push({ + kind: 'citation', + title: cit.title || cit.source || 'Citation', + summary: cit.source || cit.url || '', + url: cit.url || '#', + }); + }); + if (msg.diffSummary && Array.isArray(msg.diffSummary.files)) { + msg.diffSummary.files.forEach(function (file) { + if (!file) return; + var key = 'diff:' + file; + if (seen[key]) return; + seen[key] = true; + artifacts.push({ kind: 'diff', title: file, summary: '文件变更' }); + }); + } + (Array.isArray(msg.toolCalls) ? msg.toolCalls : []).forEach(function (tc) { + if (!tc) return; + var name = toolDisplayName({ toolName: tc.name, args: tc.args, details: tc.details }); + if (String(name || '').indexOf('mcp:') !== 0) return; + var key = 'mcp:' + (tc.id || name); + if (seen[key]) return; + seen[key] = true; + artifacts.push({ + kind: 'mcp', + title: name, + summary: extractResultText(tc.result || tc.partialResult || tc.details || tc.args).slice(0, 280), + raw: tc.result || tc.details || tc.args || {}, + }); + }); + }); + piLabState.changedFiles.forEach(function (file) { + if (!file || seen['diff:' + file]) return; + seen['diff:' + file] = true; + artifacts.push({ kind: 'diff', title: file, summary: '文件变更' }); + }); + return artifacts; + } + + function renderArtifactItem(artifact) { + var item = el('div', { + className: 'wolai-page-ai-pi-lab-artifact', + 'data-page-ai-pi-lab-artifact': artifact.kind || 'artifact', + 'data-kind': artifact.kind || 'artifact', + }); + item.appendChild(el('strong', {}, artifact.title || 'Artifact')); + if (artifact.summary) item.appendChild(el('span', {}, artifact.summary)); + var actions = el('div', { className: 'wolai-page-ai-pi-lab-artifact-actions' }); + var markdown = '- ' + (artifact.title || 'Artifact') + (artifact.summary ? ': ' + artifact.summary : '') + (artifact.url ? ' (' + artifact.url + ')' : ''); + if (artifact.kind === 'citation') { + actions.appendChild(el('a', { + href: artifact.url || '#', + target: '_blank', + rel: 'noopener', + 'data-page-ai-pi-lab-artifact-open': artifact.url || '#', + 'data-page-ai-pi-lab-artifact-title': artifact.title || 'Citation', + }, '打开引用')); + } + actions.appendChild(el('button', { + type: 'button', + 'data-page-ai-pi-lab-artifact-copy-markdown': markdown, + }, '复制 MD')); + if (artifact.raw) { + actions.appendChild(el('button', { + type: 'button', + 'data-page-ai-pi-lab-artifact-copy': compactJson(artifact.raw), + }, '复制 raw')); + } + item.appendChild(actions); + return item; + } + + function updateArtifactRail() { + if (!piLabPanelEl) return; + var container = piLabPanelEl.querySelector('[data-page-ai-pi-lab-artifacts]'); + var count = piLabPanelEl.querySelector('[data-page-ai-pi-lab-artifact-count]'); + var body = piLabPanelEl.querySelector('[data-page-ai-pi-lab-body]'); + if (!container) return; + var artifacts = collectPiArtifacts(); + if (count) count.textContent = String(artifacts.length); + if (!artifacts.length && body) body.setAttribute('data-rail-open', 'false'); + container.innerHTML = ''; + if (!artifacts.length) { + container.appendChild(el('div', { className: 'wolai-page-ai-pi-lab-receipt' }, 'No artifact yet')); + return; + } + artifacts.slice(-12).reverse().forEach(function (artifact) { + container.appendChild(renderArtifactItem(artifact)); + }); + } + function setState(newState) { piLabState.status = newState; + if (piRunViewModel) piRunViewModel.runtimeStatus = newState; updateConfig(); + maybeApplyPendingPermissionMode(); + } + + function responseJsonOrError(response, fallbackMessage) { + return response.json().catch(function () { return {}; }).then(function (payload) { + if (!response.ok) { + var message = (payload && (payload.message || payload.error)) || (fallbackMessage + ': ' + response.status); + var error = new Error(message); + error.payload = payload; + throw error; + } + return payload; + }); } function checkStatus() { @@ -842,19 +2923,34 @@ // Consume backend status/default fields when present. if (data.defaultModelProvider) piLabState.defaultModelProvider = data.defaultModelProvider; if (data.defaultModelId) piLabState.defaultModelId = data.defaultModelId; + if (data.defaultThinkingLevel) piLabState.defaultThinkingLevel = normalizeThinkingLevel(data.defaultThinkingLevel); piLabState.modelProvider = (data.session && data.session.modelProvider) || piLabState.modelProvider || null; piLabState.modelId = (data.session && data.session.modelId) || piLabState.modelId || null; + var sessionThinkingLevel = data.session && data.session.thinkingLevel; + if (sessionThinkingLevel && data.session && data.session.status === 'runtime_running') { + piLabState.thinkingLevel = normalizeThinkingLevel(sessionThinkingLevel); + } else if (!piLabState.thinkingLevel) { + piLabState.thinkingLevel = piLabState.defaultThinkingLevel; + } + piLabState.permissionMode = normalizePermissionMode((data.session && (data.session.permissionMode || (data.session.runtimePolicySnapshot && data.session.runtimePolicySnapshot.permissionMode))) || data.permissionMode || piLabState.permissionMode); piLabState.session = data.session || piLabState.session || null; piLabState.allowedRootsSummary = summarizeAllowedRoots(data.session && data.session.allowedRootsSnapshot); piLabState.runtimeMode = data.runtimeMode || (data.session && data.session.runtimeMode) || piLabState.runtimeMode || null; + piLabState.runtimeImplementation = data.runtimeImplementation || data.runtime_implementation || piLabState.runtimeImplementation || null; + piLabState.runtimeBinary = data.runtimeBinary || data.runtime_binary || piLabState.runtimeBinary || null; + piLabState.runtimeAvailable = data.runtimeAvailable !== false; + piLabState.runtimeInstallHint = data.runtimeInstallHint || data.runtime_install_hint || ''; + piLabState.runtimeError = data.runtimeError || (data.session && data.session.runtimeError) || ''; piLabState.runtimePid = data.pid || (data.session && data.session.runtimePid) || piLabState.runtimePid || null; - piLabState.disabledBuiltinTools = data.disabledPiBuiltinTools || piLabState.disabledBuiltinTools || null; + piLabState.managedBuiltinTools = data.managedPiBuiltinTools || data.managedBuiltinTools || piLabState.managedBuiltinTools || []; + piLabState.piExtensionSources = data.piExtensionSources || (data.session && data.session.piExtensionSources) || piLabState.piExtensionSources || []; + piLabState.piExtensionToolNames = data.piExtensionToolNames || piLabState.piExtensionToolNames || []; if (data.session && data.session.status === 'runtime_running') { piLabState.sessionId = data.session.sessionId; setState(STATE_STARTED); if (!piLabEventSource || piLabEventSource.readyState === EventSource.CLOSED) connectEventSource(data.session.sessionId); } - updateBuiltinDisabled(); + updateBuiltinManaged(); updateReceiptDisplay(); updateDiagnostics(''); updateMessages(); @@ -868,27 +2964,39 @@ } function startRuntime() { - if (!piLabState.enabled || piLabState.status === STATE_STARTING || piLabState.status === STATE_STREAMING) return Promise.resolve({ skipped: true }); - var context = applyCurrentContextToState(); - applyModelControls(); - setState(STATE_STARTING); - updateDiagnostics('Starting Pi runtime'); - return fetch(API.START, { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - rootUri: context.rootUri || undefined, - workspaceId: context.workspaceId || undefined, - pagePath: context.pagePath || undefined, - pageTitle: context.pageTitle || undefined, - modelProvider: piLabState.modelProvider || piLabState.defaultModelProvider, - modelId: piLabState.modelId || piLabState.defaultModelId, - }), - }) - .then(function (r) { - if (!r.ok) throw new Error('Start failed: ' + r.status); - return r.json(); + exitHistoryReplayMode(); + if (piLabStartPromise) return piLabStartPromise; + if (piLabState.status === STATE_STREAMING) return Promise.resolve({ skipped: true }); + if (piLabState.status === STATE_STARTED && piLabState.sessionId) return Promise.resolve({ ok: true, session: piLabState.session }); + piLabStartPromise = Promise.resolve() + .then(function () { + return piLabState.enabled ? null : checkStatus(); + }) + .then(function () { + if (!piLabState.enabled) throw new Error('Pi Rust 服务未启用'); + if (piLabState.runtimeAvailable === false) throw new Error(piLabState.runtimeInstallHint || 'Pi Rust runtime 不可用'); + var context = applyCurrentContextToState(); + applyModelControls(); + setState(STATE_STARTING); + updateDiagnostics('Starting Pi Rust runtime'); + return fetch(API.START, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: piLabState.sessionId || undefined, + rootUri: context.rootUri || (piLabState.session && piLabState.session.rootUri) || undefined, + workspaceId: context.workspaceId || (piLabState.session && piLabState.session.workspaceId) || undefined, + pagePath: context.pagePath || (piLabState.session && piLabState.session.pagePath) || undefined, + pageTitle: context.pageTitle || (piLabState.session && piLabState.session.pageTitle) || undefined, + modelProvider: piLabState.modelProvider || piLabState.defaultModelProvider, + modelId: piLabState.modelId || piLabState.defaultModelId, + thinkingLevel: normalizeThinkingLevel(piLabState.thinkingLevel || piLabState.defaultThinkingLevel), + permissionMode: normalizePermissionMode(piLabState.permissionMode), + }), + }).then(function (response) { + return responseJsonOrError(response, 'Start failed'); + }); }) .then(function (data) { var session = data.session || {}; @@ -896,13 +3004,22 @@ piLabState.providerSessionId = session.providerSessionId || data.providerSessionId || piLabState.providerSessionId; piLabState.modelProvider = session.modelProvider || piLabState.modelProvider || null; piLabState.modelId = session.modelId || piLabState.modelId || null; + piLabState.thinkingLevel = normalizeThinkingLevel(session.thinkingLevel || data.thinkingLevel || piLabState.thinkingLevel); + piLabState.permissionMode = normalizePermissionMode(session.permissionMode || data.permissionMode || (session.runtimePolicySnapshot && session.runtimePolicySnapshot.permissionMode) || piLabState.permissionMode); + piLabState.session = session || piLabState.session || null; piLabState.runtimeMode = session.runtimeMode || data.runtimeMode || piLabState.runtimeMode || null; + piLabState.runtimeImplementation = data.runtimeImplementation || data.runtime_implementation || piLabState.runtimeImplementation || null; + piLabState.runtimeBinary = data.runtimeBinary || data.runtime_binary || piLabState.runtimeBinary || null; + piLabState.runtimeAvailable = data.runtimeAvailable !== false; + piLabState.runtimeInstallHint = data.runtimeInstallHint || data.runtime_install_hint || ''; + piLabState.runtimeError = session.runtimeError || data.runtimeError || ''; piLabState.runtimePid = session.runtimePid || data.pid || piLabState.runtimePid || null; - piLabState.disabledBuiltinTools = data.disabledPiBuiltinTools || piLabState.disabledBuiltinTools || null; + piLabState.managedBuiltinTools = data.managedPiBuiltinTools || data.managedBuiltinTools || piLabState.managedBuiltinTools || []; + piLabState.piExtensionSources = data.piExtensionSources || piLabState.piExtensionSources || []; + piLabState.piExtensionToolNames = data.piExtensionToolNames || piLabState.piExtensionToolNames || []; connectEventSource(); setState(STATE_STARTED); updateDiagnostics('Pi runtime ready'); - piLabState.messages.push({ role: 'system', text: 'Pi session started: ' + (piLabState.sessionId || 'session') }); updateMessages(); return data; }) @@ -910,10 +3027,56 @@ setState(STATE_ERROR); updateDiagnostics('Start error: ' + err.message); throw err; + }) + .finally(function () { + piLabStartPromise = null; }); - } + return piLabStartPromise; + } - function connectEventSource(sessionId) { + function handleToolCallEventPayload(payload) { + payload = payload || {}; + applyToolCallSideEffects({ + ok: payload.allowed !== false, + toolName: payload.toolName, + result: { + rootUri: payload.rootUri, + relativePath: payload.relativePath, + diffSummary: payload.diffSummary, + }, + }); + piLabState.receipts.push({ + toolName: payload.toolName, + allowed: payload.allowed !== false, + denyReason: payload.denyReason || null, + diffSummary: payload.diffSummary || null, + citationCount: payload.citationCount || 0, + beforeFileVersion: payload.beforeFileVersion || null, + afterFileVersion: payload.afterFileVersion || null, + approvalRequired: payload.approvalRequired === true, + approvalConfirmed: payload.approvalConfirmed === true, + }); + upsertToolCall({ + toolCallId: payload.toolCallId || payload.id || ('receipt:' + (payload.toolName || 'tool') + ':' + piLabState.receipts.length), + toolName: payload.toolName || 'tool', + args: payload.params || payload.args || {}, + }, payload.allowed === false ? 'denied' : 'done', { + result: { + allowed: payload.allowed !== false, + denyReason: payload.denyReason || null, + diffSummary: payload.diffSummary || null, + citationCount: payload.citationCount || 0, + }, + approvalRequired: payload.approvalRequired === true, + approvalConfirmed: payload.approvalConfirmed === true, + toolPolicy: payload.toolPolicy || '', + }); + if (payload.normalizedFilePath && payload.diffSummary) piLabState.changedFiles.push(payload.normalizedFilePath); + updateReceiptDisplay(); + updateContextStrip(); + } + + function connectEventSource(sessionId) { if (piLabEventSource) { piLabEventSource.close(); piLabEventSource = null; @@ -928,7 +3091,9 @@ var payload = data.payload || data; piLabState.runtimePid = payload.pid || piLabState.runtimePid; piLabState.runtimeMode = payload.runtimeMode || piLabState.runtimeMode; - piLabState.disabledBuiltinTools = payload.disabledBuiltinTools || piLabState.disabledBuiltinTools; + piLabState.managedBuiltinTools = payload.managedBuiltinTools || payload.managedPiBuiltinTools || piLabState.managedBuiltinTools; + piLabState.piExtensionSources = payload.piExtensionSources || piLabState.piExtensionSources || []; + piLabState.piExtensionToolNames = payload.piExtensionToolNames || piLabState.piExtensionToolNames || []; setState(STATE_STARTED); updateDiagnostics('runtime_started'); } catch (_) {} @@ -939,41 +3104,42 @@ handlePiRpcEvent(data.payload || data); } catch (_) {} }); - es.addEventListener('tool_call', function (e) { - try { - var data = JSON.parse(e.data); - var payload = data.payload || data; - applyToolCallSideEffects({ - ok: payload.allowed !== false, - toolName: payload.toolName, - result: { - rootUri: payload.rootUri, - relativePath: payload.relativePath, - diffSummary: payload.diffSummary, - }, - }); - piLabState.receipts.push({ - toolName: payload.toolName, - allowed: payload.allowed !== false, - denyReason: payload.denyReason || null, - diffSummary: payload.diffSummary || null, - citationCount: payload.citationCount || 0, - beforeFileVersion: payload.beforeFileVersion || null, - afterFileVersion: payload.afterFileVersion || null, - }); - if (payload.normalizedFilePath && payload.diffSummary) piLabState.changedFiles.push(payload.normalizedFilePath); - updateReceiptDisplay(); - updateContextStrip(); - } catch (_) {} - }); - es.addEventListener('runtime_aborted', function () { - setState(STATE_ABORTED); - if (streamingAssistantMsg) { - streamingAssistantMsg.status = 'done'; - streamingAssistantMsg = null; - } - updateMessages(); - }); + es.addEventListener('tool_call', function (e) { + try { + var data = JSON.parse(e.data); + var payload = data.payload || data; + handleToolCallEventPayload(payload); + } catch (_) {} + }); + es.addEventListener('runtime_aborted', function (e) { + var payload = { reason: '已中止', stopReason: 'aborted' }; + try { + var data = JSON.parse(e.data); + payload = Object.assign(payload, data.payload || data || {}); + } catch (_) {} + applyPiRunEvent({ type: 'assistant_abort', payload: payload }); + setState(STATE_ABORTED); + updateMessages(); + }); + es.addEventListener('runtime_stdout_closed', function (e) { + var payload = { message: 'Pi Rust runtime stdout closed' }; + try { + var data = JSON.parse(e.data); + payload = Object.assign(payload, data.payload || data || {}); + } catch (_) {} + piLabState.runtimePid = null; + if (payload.duringTurn || piLabState.status === STATE_STREAMING) { + applyPiRunEvent({ + type: 'assistant_error', + payload: { error: payload.message || 'Pi Rust runtime 已退出,未返回回复' }, + }); + setState(STATE_ERROR); + updateMessages(); + } else { + setState(STATE_IDLE); + } + updateDiagnostics(payload.message || 'runtime_stdout_closed'); + }); es.addEventListener('error', function () { if (piLabEventSource && piLabEventSource.readyState === EventSource.CLOSED) updateDiagnostics('SSE connection closed'); }); @@ -981,11 +3147,8 @@ } function ensureStreamingAssistant() { - if (!streamingAssistantMsg) { - streamingAssistantMsg = { role: 'assistant', text: '', toolCalls: [], citations: [], diffSummary: null, status: 'streaming' }; - piLabState.messages.push(streamingAssistantMsg); - setState(STATE_STREAMING); - } + if (!streamingAssistantMsg) applyPiRunEvent({ type: 'assistant_begin' }); + setState(STATE_STREAMING); return streamingAssistantMsg; } @@ -997,35 +3160,75 @@ .join(''); } + function handlePiMessageEvent(payload) { + var message = payload && payload.message; + if (!message || !message.role) return false; + if (message.role === 'assistant') { + var content = Array.isArray(message.content) ? message.content : []; + var toolParts = content.filter(function (part) { return part && part.type === 'toolCall'; }); + toolParts.forEach(function (part) { + upsertToolCall({ + toolCallId: part.id || part.toolCallId || '', + toolName: part.name || part.toolName || 'tool', + args: part.arguments || part.args || {}, + }, 'running'); + }); + var visibleText = extractVisibleAssistantText(message); + if (visibleText) { + applyPiRunEvent({ type: 'assistant_done', text: visibleText }); + setState(STATE_STARTED); + updateMessages(); + } + return toolParts.length > 0 || !!visibleText; + } + if (message.role === 'toolResult') { + upsertToolCall({ + toolCallId: message.toolCallId || message.tool_call_id || '', + toolName: message.toolName || message.tool_name || 'tool', + details: message.details || {}, + }, message.isError ? 'error' : 'done', { + result: { + content: message.content || [], + details: message.details || {}, + }, + isError: message.isError === true, + }); + return true; + } + return false; + } + function handlePiRpcEvent(payload) { if (!payload) return; var eventType = payload.type; var msgData = payload.assistantMessageEvent || payload; var msgDataType = msgData && msgData.type; + if (handlePiExtensionUiRequest(payload)) return; if (msgDataType === 'thinking_start' || msgDataType === 'thinking_delta' || msgDataType === 'thinking_end') { updateDiagnostics('Pi thinking event hidden from visible reply'); return; } + if (eventType === 'queue_update') { + applyPiRunEvent({ type: 'queue_update', payload: payload }); + updateQueueDisplay(); + return; + } + if (eventType === 'message' && handlePiMessageEvent(payload)) return; if (msgDataType === 'text_start') { - ensureStreamingAssistant(); + setState(STATE_STREAMING); + applyPiRunEvent({ type: 'assistant_begin' }); updateMessages(); return; } if (msgDataType === 'text_delta') { - var msg = ensureStreamingAssistant(); - if (msgData.delta) msg.text += msgData.delta; - if (msgData.text && !msgData.delta) msg.text = msgData.text; + applyPiRunEvent({ type: 'assistant_delta', payload: msgData }); updateMessages(); } else if (msgDataType === 'text_end') { - var textEndMsg = ensureStreamingAssistant(); - textEndMsg.text = msgData.content || textEndMsg.text; + applyPiRunEvent({ type: 'assistant_text_end', payload: msgData }); updateMessages(); } else if (eventType === 'message_end' && payload.message && payload.message.role === 'assistant') { - var messageEndMsg = ensureStreamingAssistant(); var visibleText = extractVisibleAssistantText(payload.message); - if (visibleText) messageEndMsg.text = visibleText; - messageEndMsg.status = 'done'; - streamingAssistantMsg = null; + applyPiRunEvent({ type: 'assistant_done', text: visibleText }); setState(STATE_STARTED); updateMessages(); } else if (eventType === 'agent_end') { @@ -1041,89 +3244,112 @@ setState(STATE_STARTED); return; } - var agentEndMsg = ensureStreamingAssistant(); - if (finalText) agentEndMsg.text = finalText; - agentEndMsg.status = 'done'; - streamingAssistantMsg = null; + applyPiRunEvent({ type: 'assistant_done', text: finalText }); setState(STATE_STARTED); updateMessages(); - } else if (eventType === 'tool_call_start' || msgDataType === 'tool_call_start') { - var startMsg = ensureStreamingAssistant(); - startMsg.toolCalls.push({ name: payload.toolName || payload.name || 'tool', args: payload.args || payload.params || {}, status: 'running' }); - updateMessages(); - } else if (eventType === 'tool_call_end' || msgDataType === 'tool_call_end') { - var endMsg = ensureStreamingAssistant(); - var last = endMsg.toolCalls[endMsg.toolCalls.length - 1]; - if (last) { - last.status = 'done'; - last.result = payload.result || msgData.result || {}; + } else if (eventType === 'tool_execution_start' || eventType === 'tool_call_start' || msgDataType === 'tool_call_start' || msgDataType === 'toolcall_start') { + var startToolPayload = eventType === 'tool_execution_start' || eventType === 'tool_call_start' ? payload : assistantToolCallPayload(msgData); + if (startToolPayload) upsertToolCall(startToolPayload, 'running'); + } else if (eventType === 'tool_execution_update') { + upsertToolCall(payload, 'running', { partialResult: payload.partialResult || payload.result || null }); + } else if (eventType === 'tool_execution_end' || eventType === 'tool_call_end' || msgDataType === 'tool_call_end' || msgDataType === 'toolcall_end') { + var endToolPayload = eventType === 'tool_execution_end' || eventType === 'tool_call_end' ? payload : assistantToolCallPayload(msgData); + if (endToolPayload) { + upsertToolCall(endToolPayload, payload.isError ? 'error' : 'done', { + result: payload.result || msgData.result || endToolPayload.result || {}, + isError: payload.isError === true, + }); } + } else if (msgDataType === 'toolcall_delta') { + var deltaToolPayload = assistantToolCallPayload(msgData); + if (deltaToolPayload) upsertToolCall(deltaToolPayload, 'running', { args: deltaToolPayload.args || {} }); + } else if (eventType === 'response' && payload.command === 'abort') { + applyPiRunEvent({ type: 'assistant_abort', payload: payload }); + setState(STATE_ABORTED); updateMessages(); } else if (eventType === 'citation' || msgDataType === 'citation') { - var citMsg = ensureStreamingAssistant(); - citMsg.citations.push({ source: payload.source || msgData.source || '', title: payload.title || msgData.title || '', url: payload.url || msgData.url || '#' }); + applyPiRunEvent({ type: 'citation', payload: { source: payload.source || msgData.source || '', title: payload.title || msgData.title || '', url: payload.url || msgData.url || '#' } }); updateMessages(); } else if (eventType === 'diff' || msgDataType === 'diff') { - var diffMsg = ensureStreamingAssistant(); - diffMsg.diffSummary = diffMsg.diffSummary || { files: [] }; - diffMsg.diffSummary.files = payload.files || msgData.files || []; - diffMsg.diffSummary.files.forEach(function (file) { if (file) piLabState.changedFiles.push(file); }); + var diffFiles = payload.files || msgData.files || []; + applyPiRunEvent({ type: 'diff', payload: { files: diffFiles } }); + diffFiles.forEach(function (file) { if (file) piLabState.changedFiles.push(file); }); updateMessages(); } else if (eventType === 'done' || msgDataType === 'done') { - var doneMsg = ensureStreamingAssistant(); - doneMsg.status = 'done'; - if (msgData.text) doneMsg.text = msgData.text; - if (msgData.toolCalls) doneMsg.toolCalls = msgData.toolCalls; - if (msgData.citations) doneMsg.citations = msgData.citations; - if (msgData.diffSummary) doneMsg.diffSummary = msgData.diffSummary; - streamingAssistantMsg = null; + applyPiRunEvent({ + type: 'assistant_done', + payload: { + text: msgData.text, + toolCalls: msgData.toolCalls, + citations: msgData.citations, + diffSummary: msgData.diffSummary, + }, + }); setState(STATE_STARTED); updateMessages(); } else if (eventType === 'error' || msgDataType === 'error') { - var errMsg = ensureStreamingAssistant(); - errMsg.text += (errMsg.text ? '\n' : '') + '错误: ' + (payload.error || msgData.message || 'Pi runtime error'); - errMsg.status = 'done'; - streamingAssistantMsg = null; + applyPiRunEvent({ type: 'assistant_error', payload: { error: payload.error || msgData.message || 'Pi runtime error' } }); setState(STATE_STARTED); updateMessages(); } } - function sendPrompt(text) { + function sendPrompt(text, options) { var prompt = String(text || '').trim(); if (!prompt) return; - piLabState.messages.push({ role: 'user', text: prompt }); - streamingAssistantMsg = { role: 'assistant', text: '', toolCalls: [], citations: [], diffSummary: null, status: 'streaming' }; - piLabState.messages.push(streamingAssistantMsg); + var streamingBehavior = options && options.streamingBehavior; + var midStream = piLabState.status === STATE_STREAMING && streamingAssistantMsg; + applyPiRunEvent({ type: 'user_prompt', text: prompt, meta: midStream ? (streamingBehavior === 'followUp' ? 'follow-up queued' : 'steer') : '' }); + if (!midStream) applyPiRunEvent({ type: 'assistant_begin' }); setState(STATE_STREAMING); updateMessages(); + var context = applyCurrentContextToState(); + var contextSelection = selectedContextPayload(context); + var refs = contextSelection.refs || []; + var selectedFolderPath = currentFolderPathFromPagePath(context.pagePath); + var includeAnyContext = refs.length > 0; + var includePageContext = refs.indexOf('current_page') >= 0 || refs.indexOf('selection') >= 0 || refs.indexOf('lightrag') >= 0; + var includeFolderContext = refs.indexOf('folder') >= 0; + var body = { + sessionId: piLabState.sessionId, + message: prompt, + rootUri: includeAnyContext ? (context.rootUri || undefined) : undefined, + workspaceId: includeAnyContext ? (context.workspaceId || undefined) : undefined, + pagePath: includePageContext ? (context.pagePath || undefined) : undefined, + pageTitle: includePageContext ? (context.pageTitle || undefined) : undefined, + folderPath: includeFolderContext && selectedFolderPath !== null ? selectedFolderPath : undefined, + contextRefs: contextSelection.refs, + selectedContext: contextSelection, + }; + if (streamingBehavior) body.streamingBehavior = streamingBehavior; return fetch(API.SEND, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: piLabState.sessionId, message: prompt }), + body: JSON.stringify(body), }) .then(function (r) { - if (!r.ok) throw new Error('Send failed: ' + r.status); - return r.json(); + return responseJsonOrError(r, 'Send failed'); }) .then(function (data) { if (!data.accepted) throw new Error('Pi Lab rejected prompt'); if (!piLabEventSource || piLabEventSource.readyState === EventSource.CLOSED) connectEventSource(data.sessionId || piLabState.sessionId); }) .catch(function (err) { - if (streamingAssistantMsg) { - streamingAssistantMsg.text = '错误: ' + err.message; - streamingAssistantMsg.status = 'done'; - streamingAssistantMsg = null; - setState(STATE_STARTED); - updateMessages(); - } + if (streamingAssistantMsg) applyPiRunEvent({ type: 'assistant_error', message: err.message }); + setState(STATE_STARTED); + updateMessages(); }); } function ensureRuntimeReady() { - if (piLabState.status === STATE_IDLE || piLabState.status === STATE_ABORTED || piLabState.status === STATE_ERROR || !piLabState.sessionId) { + if (piLabState.status === STATE_STARTED && piLabState.sessionId) { + return Promise.resolve({ ok: true, session: piLabState.session }); + } + if (piLabState.status === STATE_STREAMING && piLabState.sessionId) { + return Promise.resolve({ ok: true, session: piLabState.session }); + } + if (piLabState.status === STATE_STARTING || piLabState.status === STATE_IDLE || piLabState.status === STATE_ABORTED || piLabState.status === STATE_ERROR || !piLabState.sessionId) { return startRuntime(); } return Promise.resolve({ ok: true }); @@ -1144,6 +3370,10 @@ }).then(function (response) { return response.json().catch(function () { return {}; }).then(function (payload) { if (!response.ok) throw new Error(payload.message || ('Tool call failed: ' + response.status)); + if (payload && payload.ok === false) { + var result = payload.result || {}; + throw new Error(result.message || payload.message || result.code || 'Tool call failed'); + } return payload; }); }); @@ -1221,59 +3451,72 @@ var context = applyCurrentContextToState(); if (kind === 'read-page') { if (!context.rootUri || !context.pagePath) { - setComposerText('读取当前页面,总结页面要点。'); - updateDiagnostics('当前页缺少 rootUri/pagePath,无法调用 mnote.current_page.read'); + showPiToast('当前页未绑定文件上下文', 'warning'); + updateDiagnostics('当前页缺少 rootUri/pagePath,无法作为 Pi 上下文'); return; } - callMNoteTool('mnote.current_page.read', { - rootUri: context.rootUri, - pagePath: context.pagePath, - }).then(function (payload) { - var result = payload.result || {}; - var content = String(result.content || '').slice(0, 12000); - piLabState.messages.push({ - role: 'system', - text: '已读取当前页:' + (context.pageTitle || context.pagePath) + '\nfileVersion=' + (result.fileVersion || 'unknown'), - }); - setComposerText('基于当前页面内容回答:\n\n' + content); - updateMessages(); - }).catch(function (error) { - updateDiagnostics(error.message || String(error)); - setComposerText('读取当前页面,总结页面要点。'); - }); + toggleContextQuick(kind); + showPiToast((contextSelectionState().currentPage ? '已选择' : '已取消') + '当前页上下文', 'info'); + return; + } + if (kind === 'current-folder') { + if (!context.rootUri || currentFolderPathFromPagePath(context.pagePath) === null) { + showPiToast('当前页没有可用文件夹上下文', 'warning'); + updateDiagnostics('当前页缺少 rootUri/folderPath,无法作为 Pi 文件夹上下文'); + return; + } + toggleContextQuick(kind); + showPiToast((contextSelectionState().currentFolder ? '已选择' : '已取消') + '当前文件夹上下文', 'info'); return; } if (kind === 'selection') { var selection = refreshSelectionSummary(); - callMNoteTool('mnote.selection.read', { - selection: selection, - selectionSource: 'mnote_sidebar_host', - rootUri: context.rootUri || null, - pagePath: context.pagePath || null, - }).then(function () { - setComposerText(selection - ? '基于当前选区给出修改建议:\n\n' + selection - : '当前没有选区。请先在编辑器中选中文本,再让 Pi 读取选区。'); - updateContextStrip(); - }).catch(function () { - setComposerText(selection || '当前没有选区。'); - }); + if (!selection && !contextSelectionState().selection) showPiToast('当前没有选区', 'warning'); + toggleContextQuick(kind); return; } - setComposerText('通过 LightRAG 查询当前页面相关资料,并带 citation 回复。'); + toggleContextQuick('rag'); + showPiToast((contextSelectionState().lightrag ? '已启用' : '已关闭') + ' LightRAG 上下文', 'info'); } - function sendCurrentInput() { + function sendCurrentInput(options) { if (!piLabPanelEl) return; var input = piLabPanelEl.querySelector('[data-page-ai-pi-lab-input]'); var text = input ? input.value.trim() : ''; if (!text) return; + if (piLabState.status === STATE_IDLE || piLabState.status === STATE_ABORTED || piLabState.status === STATE_ERROR || !piLabState.sessionId) { + showPiToast('Pi Rust 正在启动,启动后自动发送', 'info'); + ensureRuntimeReady().then(function () { + if (!piLabState.sessionId) throw new Error('Pi Rust 会话尚未就绪'); + if (input) input.value = ''; + updateButtons(); + return sendPrompt(text, { streamingBehavior: undefined }); + }).catch(function (err) { + showPiToast(err && err.message ? err.message : 'Pi Rust 启动失败', 'warning'); + updateDiagnostics(err && err.message ? err.message : 'Pi Rust 启动失败'); + updateButtons(); + }); + return; + } + if (piLabState.status === STATE_STARTING) { + showPiToast('Pi Rust 正在启动,启动后自动发送', 'info'); + ensureRuntimeReady().then(function () { + if (!piLabState.sessionId) throw new Error('Pi Rust 会话尚未就绪'); + if (input) input.value = ''; + updateButtons(); + return sendPrompt(text, { streamingBehavior: undefined }); + }).catch(function (err) { + showPiToast(err && err.message ? err.message : 'Pi Rust 启动失败', 'warning'); + updateDiagnostics(err && err.message ? err.message : 'Pi Rust 启动失败'); + updateButtons(); + }); + return; + } if (input) input.value = ''; updateButtons(); - var run = piLabState.status === STATE_IDLE || piLabState.status === STATE_ABORTED || piLabState.status === STATE_ERROR - ? startRuntime() - : Promise.resolve(); - run.then(function () { return sendPrompt(text); }).catch(function () {}); + var isMidStream = piLabState.status === STATE_STREAMING; + var streamingBehavior = isMidStream ? ((options && options.streamingBehavior) || 'steer') : undefined; + sendPrompt(text, { streamingBehavior: streamingBehavior }).catch(function () {}); } function abortPrompt() { @@ -1281,21 +3524,56 @@ piLabEventSource.close(); piLabEventSource = null; } - if (piLabState.sessionId) { + if (piLabState.sessionId) { + fetch(API.ABORT, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: piLabState.sessionId }), + }).then(function (response) { + return response.json().catch(function () { return {}; }); + }).then(function (payload) { + applyPiRunEvent({ type: 'assistant_abort', payload: Object.assign({ stopReason: 'aborted' }, payload || {}) }); + updateMessages(); + }).catch(function () {}); + } + applyPiRunEvent({ type: 'assistant_abort', payload: { reason: '已中止', stopReason: 'aborted' } }); + setState(STATE_ABORTED); + updateMessages(); + } + + function startNewConversation() { + var previousSessionId = piLabState.sessionId; + if (piLabEventSource) { + piLabEventSource.close(); + piLabEventSource = null; + } + if (previousSessionId && !piLabState.viewingHistorySessionId && (piLabState.status === STATE_STARTED || piLabState.status === STATE_STREAMING || piLabState.status === STATE_STARTING)) { fetch(API.ABORT, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: piLabState.sessionId }), + body: JSON.stringify({ sessionId: previousSessionId }), }).catch(function () {}); } - if (streamingAssistantMsg) { - streamingAssistantMsg.text += (streamingAssistantMsg.text ? '\n' : '') + '已中止'; - streamingAssistantMsg.status = 'done'; - streamingAssistantMsg = null; - } - setState(STATE_ABORTED); + closeActionMenu(); + setHistoryPanelVisible(false); + exitHistoryReplayMode(); + piLabState.sessionId = null; + piLabState.providerSessionId = null; + piLabState.session = null; + piLabState.thinkingLevel = normalizeThinkingLevel(piLabState.defaultThinkingLevel || DEFAULT_THINKING_LEVEL); + piLabState.status = STATE_IDLE; + resetPiRunViewModel(); + piLabState.receipts = []; updateMessages(); + updateReceiptDisplay(); + updateQueueDisplay(); + updateThinkingControl(); + updateConfig(); + if (piLabState.active) { + startRuntime().catch(function () {}); + } } function ensurePanel() { @@ -1305,6 +3583,7 @@ piLabPanelEl = container.firstElementChild; wireEvents(); updateReceiptDisplay(); + updateArtifactRail(); updateMessages(); return piLabPanelEl; } @@ -1342,7 +3621,12 @@ setDrawerVisible(true); applyCurrentContextToState(); updateConfig(); - checkStatus(); + checkStatus().then(function () { + if (!piLabState.active) return; + if (piLabState.status === STATE_IDLE || piLabState.status === STATE_ABORTED || piLabState.status === STATE_ERROR || !piLabState.sessionId) { + return startRuntime(); + } + }).catch(function () {}); var input = piLabPanelEl && piLabPanelEl.querySelector('[data-page-ai-pi-lab-input]'); if (input) setTimeout(function () { input.focus(); }, 60); } @@ -1376,7 +3660,6 @@ if (!piLabPanelEl || piLabPanelEl.getAttribute('data-wired') === 'true') return; piLabPanelEl.setAttribute('data-wired', 'true'); var input = piLabPanelEl.querySelector('[data-page-ai-pi-lab-input]'); - var startBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-btn-start]'); var sendBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-btn-send]'); var abortBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-btn-abort]'); var clearBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-btn-clear]'); @@ -1385,20 +3668,19 @@ var providerSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-provider]'); var modelSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-id]'); var bottomModelSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-id-bottom]'); + var thinkingSelect = piLabPanelEl.querySelector('[data-page-ai-pi-lab-thinking]'); var customInput = piLabPanelEl.querySelector('[data-page-ai-pi-lab-model-custom]'); var settingsBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-open-settings]'); var historyBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-history]'); - var openBtn = piLabPanelEl.querySelector('[data-page-ai-pi-lab-open]'); - var newBtns = piLabPanelEl.querySelectorAll('[data-page-ai-pi-lab-new], [data-page-ai-pi-lab-new-small]'); - if (startBtn) startBtn.addEventListener('click', startRuntime); + var newBtns = piLabPanelEl.querySelectorAll('[data-page-ai-pi-lab-new]'); if (sendBtn) sendBtn.addEventListener('click', sendCurrentInput); if (abortBtn) abortBtn.addEventListener('click', abortPrompt); if (clearBtn) clearBtn.addEventListener('click', function () { - piLabState.messages = []; + resetPiRunViewModel(); piLabState.receipts = []; - streamingAssistantMsg = null; updateMessages(); updateReceiptDisplay(); + updateQueueDisplay(); }); if (closeBtn) closeBtn.addEventListener('click', hidePiLab); if (minimizeBtn) { @@ -1414,7 +3696,7 @@ if (e.isComposing || e.key === 'Process') return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - sendCurrentInput(); + sendCurrentInput(e.altKey ? { streamingBehavior: 'followUp' } : undefined); } if (e.key === 'Escape' && piLabState.status === STATE_STREAMING) { e.preventDefault(); @@ -1422,46 +3704,154 @@ } }); } - [providerSelect, modelSelect, bottomModelSelect].forEach(function (node) { + [providerSelect, modelSelect, bottomModelSelect, thinkingSelect].forEach(function (node) { if (node) node.addEventListener('change', applyModelControls); }); if (customInput) customInput.addEventListener('input', applyModelControls); - if (settingsBtn) { - settingsBtn.addEventListener('click', function () { - var settings = piLabPanelEl.querySelector('[data-page-ai-pi-lab-settings]'); - var drawer = ensureDrawer(); - var isOpen = drawer.getAttribute('data-config-open') === 'true'; - drawer.setAttribute('data-config-open', isOpen ? 'false' : 'true'); - if (settings) settings.open = !isOpen; - var context = piLabPanelEl.querySelector('[data-page-ai-pi-lab-context-strip]'); - if (context) context.open = !isOpen; - }); - } - if (historyBtn) { - historyBtn.addEventListener('click', function () { - var panel = piLabPanelEl.querySelector('[data-page-ai-pi-lab-history-panel]'); - if (!panel) return; - panel.hidden = !panel.hidden; - if (!panel.hidden) loadHistory().catch(function (error) { - panel.innerHTML = '
    ' + escapeHtml(error.message || '历史加载失败') + '
    '; - }); - }); - } - if (openBtn) { - openBtn.addEventListener('click', function () { - window.location.assign('/user/ai'); + if (settingsBtn) { + settingsBtn.addEventListener('click', function () { + window.location.assign('/user/ai#ai-admin-access'); }); } + if (historyBtn) { + historyBtn.addEventListener('click', function () { + var layer = piLabPanelEl.querySelector('[data-page-ai-pi-lab-history-layer]'); + if (!layer) return; + setHistoryPanelVisible(layer.hidden); + }); + } Array.prototype.slice.call(newBtns || []).forEach(function (btn) { - btn.addEventListener('click', function () { - piLabState.messages = []; - piLabState.receipts = []; - streamingAssistantMsg = null; - updateMessages(); - updateReceiptDisplay(); - }); + btn.addEventListener('click', startNewConversation); }); - piLabPanelEl.addEventListener('click', function (event) { + piLabPanelEl.addEventListener('click', function (event) { + var historyNew = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-new-small]') : null; + if (historyNew) { + startNewConversation(); + return; + } + var actionMenuToggle = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-action-menu-toggle]') : null; + if (actionMenuToggle) { + setActionMenuOpen(!piLabState.actionMenuOpen); + return; + } + var menuAction = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-menu-action]') : null; + if (menuAction && !menuAction.disabled) { + handleActionMenuAction(menuAction.getAttribute('data-page-ai-pi-lab-menu-action')); + return; + } + var permissionBtn = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-permission]') : null; + if (permissionBtn) { + closeActionMenu(); + setPermissionMenuOpen(!piLabState.permissionMenuOpen); + return; + } + var permissionMode = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-permission-mode]') : null; + if (permissionMode) { + selectPermissionMode(permissionMode.getAttribute('data-page-ai-pi-lab-permission-mode')); + return; + } + var closeHistory = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-close], [data-page-ai-pi-lab-history-scrim]') : null; + if (closeHistory) { + setHistoryPanelVisible(false); + return; + } + var refreshHistory = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-refresh]') : null; + if (refreshHistory) { + loadHistory().catch(function (error) { + updateDiagnostics(error.message || '历史刷新失败'); + showPiToast('历史刷新失败', 'error'); + }); + return; + } + var clearHistory = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-clear]') : null; + if (clearHistory && !clearHistory.disabled) { + clearHistorySessions().catch(function (error) { + updateDiagnostics(error.message || '历史清空失败'); + showPiToast('历史清空失败', 'error'); + }); + return; + } + var forkCurrent = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-fork-current]') : null; + if (forkCurrent) { + forkHistorySession(piLabState.viewingHistorySessionId || piLabState.sessionId).catch(function (error) { + updateDiagnostics(error.message || '历史会话 fork 失败'); + showPiToast('Fork failed', 'error'); + }); + return; + } + var closeArtifacts = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-close-artifacts]') : null; + if (closeArtifacts) { + var closeBody = piLabPanelEl.querySelector('[data-page-ai-pi-lab-body]'); + if (closeBody) closeBody.setAttribute('data-rail-open', 'false'); + return; + } + var copyMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-copy-markdown]') : null; + if (copyMarkdown) { + copyTextToClipboard(copyMarkdown.getAttribute('data-page-ai-pi-lab-copy-markdown') || '').then(function () { + showPiToast('Markdown copied', 'success'); + }); + return; + } + var openSessionJsonl = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-open-session-jsonl]') : null; + if (openSessionJsonl) { + openCurrentSessionJsonl(); + return; + } + var openArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-open]') : null; + if (openArtifact) { + window.dispatchEvent(new CustomEvent('mnote:open-reference', { + detail: { + source: 'page_ai_pi_lab_artifact', + title: openArtifact.getAttribute('data-page-ai-pi-lab-artifact-title') || openArtifact.textContent || '', + url: openArtifact.getAttribute('data-page-ai-pi-lab-artifact-open') || openArtifact.getAttribute('href') || '', + }, + })); + } + var copyArtifactMarkdown = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy-markdown]') : null; + if (copyArtifactMarkdown) { + copyTextToClipboard(copyArtifactMarkdown.getAttribute('data-page-ai-pi-lab-artifact-copy-markdown') || '').then(function () { + showPiToast('Artifact markdown copied', 'success'); + }); + return; + } + var copyArtifact = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-artifact-copy]') : null; + if (copyArtifact) { + var raw = copyArtifact.getAttribute('data-page-ai-pi-lab-artifact-copy') || ''; + copyTextToClipboard(raw).then(function () { showPiToast('Artifact raw copied', 'success'); }); + return; + } + var deleteTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-delete]') : null; + if (deleteTarget) { + deleteHistorySession(deleteTarget.getAttribute('data-page-ai-pi-lab-history-delete')).catch(function (error) { + updateDiagnostics(error.message || '历史会话删除失败'); + showPiToast('Delete failed', 'error'); + }); + return; + } + var exportTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-export]') : null; + if (exportTarget) { + exportHistorySession(exportTarget.getAttribute('data-page-ai-pi-lab-history-export')).catch(function (error) { + updateDiagnostics(error.message || '历史会话导出失败'); + showPiToast('Export failed', 'error'); + }); + return; + } + var renameTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-rename]') : null; + if (renameTarget) { + renameHistorySession(renameTarget.getAttribute('data-page-ai-pi-lab-history-rename')).catch(function (error) { + updateDiagnostics(error.message || '历史会话重命名失败'); + showPiToast('Rename failed', 'error'); + }); + return; + } + var forkTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-fork]') : null; + if (forkTarget) { + forkHistorySession(forkTarget.getAttribute('data-page-ai-pi-lab-history-fork')).catch(function (error) { + updateDiagnostics(error.message || '历史会话 fork 失败'); + showPiToast('Fork failed', 'error'); + }); + return; + } var historyTarget = event.target && event.target.closest ? event.target.closest('[data-page-ai-pi-lab-history-session]') : null; if (historyTarget) { openHistorySession(historyTarget.getAttribute('data-page-ai-pi-lab-history-session')).catch(function (error) { @@ -1524,5 +3914,16 @@ if (typeof window !== 'undefined') { window.createSidebarPageAiPiLabRuntime = createSidebarPageAiPiLabRuntime; + if (window.__MNOTE_PI_LAB_TEST__ === true) window.__mnotePiLabReducerTest = { + createPiRunViewModel: createPiRunViewModel, + reducePiRunViewModel: reducePiRunViewModel, + rebuildPiRunToolIndex: rebuildPiRunToolIndex, + }; + if (window.__MNOTE_PI_LAB_TEST__ === true) window.__mnotePiLabTest = { + emitRpcEvent: handlePiRpcEvent, + emitToolCall: handleToolCallEventPayload, + showToast: showPiToast, + closeDialog: closePiUiDialog, + }; } })(); diff --git a/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js b/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js index c3a30f02..499992c3 100644 --- a/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-workspace-runtime.js @@ -755,12 +755,6 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => { if (existing && existing.parentElement) existing.parentElement.removeChild(existing); } - function closeAdminAccessPolicyDialog() { - var existing = document.querySelector('[data-testid="mnote-admin-access-policy-modal"]'); - if (existing && existing.parentElement) existing.parentElement.removeChild(existing); - document.documentElement.removeAttribute('data-mnote-admin-access-policy-modal-open'); - } - function accountMenuFallbackSession() { var body = document.body instanceof HTMLElement ? document.body : null; var actorId = body ? String(body.getAttribute('data-mnote-actor-id') || '').trim() : ''; @@ -876,43 +870,6 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => { if (closeButton instanceof HTMLElement) closeButton.focus(); } - function openAdminAccessPolicyDialog(trigger) { - closeAdminAccessPolicyDialog(); - closeAccountMenu(); - if (!(trigger instanceof HTMLElement)) return; - var role = trigger.getAttribute('data-access-policy-role') === 'admin' ? 'admin' : 'user'; - var template = document.querySelector('[data-testid="mnote-admin-access-policy-template-' + role + '"]'); - var dialog = document.createElement('div'); - dialog.className = 'mnote-admin-policy-modal'; - dialog.setAttribute('data-testid', 'mnote-admin-access-policy-modal'); - dialog.setAttribute('role', 'dialog'); - dialog.setAttribute('aria-modal', 'true'); - dialog.setAttribute('aria-labelledby', 'mnote-admin-policy-dialog-title'); - dialog.innerHTML = - '
    ' + - '
    ' + - '' + - '
    ' + - '
    '; - var content = dialog.querySelector('[data-testid="mnote-admin-access-policy-modal-content"]'); - if (content) { - content.innerHTML = template ? template.innerHTML : '
    授权管理面板加载失败
    '; - } - if (typeof window.MNOTEInitAccessPolicyPanel === 'function') { - window.MNOTEInitAccessPolicyPanel(dialog); - } - dialog.querySelectorAll('[data-admin-access-policy-close]').forEach(function(button) { - button.addEventListener('click', function(event) { - event.preventDefault(); - closeAdminAccessPolicyDialog(); - }); - }); - document.body.appendChild(dialog); - document.documentElement.setAttribute('data-mnote-admin-access-policy-modal-open', 'true'); - var closeButton = dialog.querySelector('.mnote-admin-policy-modal__close'); - if (closeButton instanceof HTMLElement) closeButton.focus(); - } - async function signOutAccount(trigger) { setCommandPending(trigger, true); try { @@ -949,8 +906,7 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => { menu.setAttribute('role', 'menu'); menu.innerHTML = '' + - '' + - '' + + '' + '' + ''; var sessionPromise = fetchAccountSession(); @@ -961,18 +917,6 @@ export const createSidebarWorkspaceRuntime = (dependencies = {}) => { sessionPromise.then(openProfileDialog); }); } - var accessPolicyLink = menu.querySelector('[data-testid="mnote-account-access-policy"]'); - sessionPromise.then(function(session) { - if (!(accessPolicyLink instanceof HTMLElement)) return; - accessPolicyLink.hidden = false; - accessPolicyLink.setAttribute('data-access-policy-role', sessionIsAdmin(session) ? 'admin' : 'user'); - }); - if (accessPolicyLink) { - accessPolicyLink.addEventListener('click', function(event) { - event.preventDefault(); - openAdminAccessPolicyDialog(accessPolicyLink); - }); - } var aiManagementLink = menu.querySelector('[data-testid="mnote-account-ai-management"]'); sessionPromise.then(function(session) { if (!(aiManagementLink instanceof HTMLElement)) return; diff --git a/rust/crates/mnote-web/src/routes/ai_settings.rs b/rust/crates/mnote-web/src/routes/ai_settings.rs index 760922c3..e2592e43 100644 --- a/rust/crates/mnote-web/src/routes/ai_settings.rs +++ b/rust/crates/mnote-web/src/routes/ai_settings.rs @@ -33,6 +33,7 @@ pub struct EffectiveAiSettings { pub tool_catalog: Vec, pub skills: Vec, pub mcp_servers: Vec, + pub pi_extensions: Vec, pub lightrag_provider: LightRagProviderRef, pub access_policy_links: AccessPolicyLinks, /// Always `"directory_grants"` — declares that allowed-roots come from @@ -137,6 +138,28 @@ pub struct UserAiPolicyBody { pub skills: Option>, #[serde(default)] pub mcp_servers: Option>, + #[serde(default)] + pub pi_extensions: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryAccessRequestBody { + #[serde(default)] + pub root_path: String, + #[serde(default)] + pub root_uri: String, + #[serde(default)] + pub permission: String, + #[serde(default)] + pub note: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryAccessDecisionBody { + #[serde(default)] + pub reason: String, } // ─── Admin body types ─────────────────────────────────────────────────── @@ -171,6 +194,9 @@ pub struct UpsertAiPolicyBody { /// MCP server registry. #[serde(default)] pub mcp_servers: Option>, + /// Pi package/extension registry. + #[serde(default)] + pub pi_extensions: Option>, /// Optional quota override (merged into existing). #[serde(default)] pub quota: Option, @@ -249,6 +275,105 @@ pub struct McpServerConfig { pub required_scopes: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiExtensionConfig { + pub name: String, + pub enabled: bool, + #[serde(default)] + pub description: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub tool_names: Vec, + #[serde(default)] + pub risk_level: String, + #[serde(default)] + pub required_scopes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AiRuntimeResolvedModel { + pub provider: String, + pub model_id: String, + pub model_ref: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EffectiveAiRuntimePolicy { + pub default_model: String, + pub allowed_models: Vec, + pub enabled_skills: Vec, + pub enabled_skill_sources: Vec, + pub enabled_mcp_servers: Vec, + pub enabled_pi_extensions: Vec, + pub enabled_pi_extension_sources: Vec, + pub pi_extension_tool_names: Vec, + pub pi_extensions: HashMap, + pub mcp_servers: Value, + pub mcp_bridge: String, + pub mnote_tool_names: Vec, + pub mnote_tool_policies: HashMap, + pub native_mcp_supported: bool, +} + +impl EffectiveAiRuntimePolicy { + pub(crate) fn resolve_requested_model( + &self, + requested_provider: Option<&str>, + requested_model_id: Option<&str>, + ) -> Result { + let default_ref = normalize_model_ref(None, &self.default_model) + .unwrap_or_else(|| normalize_model_ref(None, DEFAULT_PI_MODEL).expect("default model")); + let default_provider = default_ref + .split_once('/') + .map(|(provider, _)| provider.to_string()) + .unwrap_or_else(|| "omniroute".into()); + let requested_model_id = requested_model_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let requested_provider = requested_provider + .map(str::trim) + .filter(|value| !value.is_empty()); + let model_ref = if let Some(model_id) = requested_model_id { + let normalized = + normalize_model_ref(requested_provider.or(Some(&default_provider)), model_id) + .ok_or_else(|| "请求模型为空".to_string())?; + if let (Some(provider), Some((model_provider, _))) = + (requested_provider, normalized.split_once('/')) + { + if provider != model_provider { + return Err(format!( + "请求模型 {normalized} 与 provider {provider} 不一致" + )); + } + } + normalized + } else { + default_ref + }; + let allowed = self + .allowed_models + .iter() + .filter_map(|value| normalize_model_ref(Some(&default_provider), value)) + .any(|value| value == model_ref); + if !allowed { + return Err(format!("模型 {model_ref} 不在当前 AI 设置允许范围内")); + } + let (provider, model_id) = model_ref + .split_once('/') + .map(|(provider, model_id)| (provider.to_string(), model_id.to_string())) + .unwrap_or_else(|| ("omniroute".into(), model_ref.clone())); + Ok(AiRuntimeResolvedModel { + provider, + model_id, + model_ref, + }) + } +} + // ─── Admin response types ────────────────────────────────────────────── #[derive(Debug, Serialize)] @@ -263,6 +388,7 @@ pub struct AdminAiSettingsResponse { pub tools: HashMap, pub skills: HashMap, pub mcp_servers: HashMap, + pub pi_extensions: HashMap, pub quota: Value, pub revision: i64, pub updated_at: String, @@ -332,6 +458,10 @@ pub async fn effective_settings( .into_values() .filter(|server| server.enabled && server.facade_only && server.sandbox) .collect(); + let pi_extensions = effective_pi_extension_registry(&model_policy) + .into_values() + .filter(|extension| extension.enabled) + .collect(); Ok(Json(EffectiveAiSettings { default_model: default_model.clone(), @@ -343,6 +473,7 @@ pub async fn effective_settings( tool_catalog: effective_tool_catalog(&tool_policies), skills, mcp_servers, + pi_extensions, lightrag_provider: LightRagProviderRef { provider: LIGHTRAG_PROVIDER, description: LIGHTRAG_PROVIDER_DESCRIPTION, @@ -435,6 +566,183 @@ pub async fn admin_receipts( load_receipts(&state, user_id, query.session_id.as_deref(), query.limit) } +pub async fn user_directory_access_requests( + State(state): State, + Extension(context): Extension, +) -> Result, WebError> { + let actor_id = ensure_authenticated(&context)?; + let requests = list_directory_access_requests(&state, Some(&actor_id))?; + Ok(Json(json!({ + "ok": true, + "schema": "mnote.settings.directory-access-requests.v1", + "requests": requests, + }))) +} + +pub async fn create_directory_access_request( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result, WebError> { + let actor_id = ensure_authenticated(&context)?; + let root_path = body.root_path.trim(); + let root_uri = body.root_uri.trim(); + if root_path.is_empty() && root_uri.is_empty() { + return Err(WebError::bad_request_code( + "directory_access_request_root_required", + "申请目录权限需要提供目录路径", + ) + .with_context(&context)); + } + let permission = normalize_directory_request_permission(&body.permission); + let request_id = format!("dirreq_{}", uuid::Uuid::new_v4().simple()); + let metadata = json!({ + "requestId": request_id, + "userId": actor_id, + "rootPath": root_path, + "rootUri": root_uri, + "permission": permission, + "note": body.note.trim(), + "status": "pending", + }); + state + .control_plane() + .append_audit(AppendAuditInput { + actor_user_id: Some(actor_id.clone()), + action: "directory_access.requested".into(), + target_kind: "directory_access_request".into(), + target_id: Some(request_id.clone()), + metadata_json: metadata.to_string(), + }) + .map_err(|error| WebError::internal(format!("目录权限申请写入失败: {error}")))?; + Ok(Json(json!({ + "ok": true, + "schema": "mnote.settings.directory-access-request.created.v1", + "request": metadata, + }))) +} + +pub async fn admin_directory_access_requests( + State(state): State, + Extension(context): Extension, +) -> Result, WebError> { + ensure_authenticated(&context)?; + ensure_admin(&context)?; + let requests = list_directory_access_requests(&state, None)?; + Ok(Json(json!({ + "ok": true, + "schema": "mnote.admin.settings.directory-access-requests.v1", + "requests": requests, + }))) +} + +pub async fn approve_directory_access_request( + State(state): State, + Extension(context): Extension, + Path(request_id): Path, + Json(_body): Json, +) -> Result, WebError> { + let actor_id = ensure_authenticated(&context)?; + ensure_admin(&context)?; + let request = find_pending_directory_access_request(&state, &request_id)?; + let user_id = request + .get("userId") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string(); + let root_path = request + .get("rootPath") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string(); + let root_uri = request + .get("rootUri") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string(); + let permission = request + .get("permission") + .and_then(Value::as_str) + .map(normalize_directory_request_permission) + .unwrap_or_else(|| "read".into()); + let (_, Json(created)) = local_folder_source::create_local_access_grant( + State(state.clone()), + Extension(context.clone()), + Json(local_folder_source::LocalAccessGrantRequest { + id: String::new(), + user_id, + root_uri, + root_path, + permission, + recursive: true, + capabilities: Vec::new(), + }), + ) + .await?; + let grant_id = created + .pointer("/grant/id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let metadata = json!({ + "requestId": request_id, + "status": "approved", + "grantId": grant_id, + "decidedBy": actor_id, + }); + state + .control_plane() + .append_audit(AppendAuditInput { + actor_user_id: Some(actor_id), + action: "directory_access.approved".into(), + target_kind: "directory_access_request".into(), + target_id: Some(request_id), + metadata_json: metadata.to_string(), + }) + .map_err(|error| WebError::internal(format!("目录权限审批记录写入失败: {error}")))?; + Ok(Json(json!({ + "ok": true, + "schema": "mnote.admin.settings.directory-access-request.approved.v1", + "request": metadata, + "grant": created.get("grant").cloned().unwrap_or(Value::Null), + }))) +} + +pub async fn reject_directory_access_request( + State(state): State, + Extension(context): Extension, + Path(request_id): Path, + Json(body): Json, +) -> Result, WebError> { + let actor_id = ensure_authenticated(&context)?; + ensure_admin(&context)?; + let _ = find_pending_directory_access_request(&state, &request_id)?; + let metadata = json!({ + "requestId": request_id, + "status": "rejected", + "reason": body.reason.trim(), + "decidedBy": actor_id, + }); + state + .control_plane() + .append_audit(AppendAuditInput { + actor_user_id: Some(actor_id), + action: "directory_access.rejected".into(), + target_kind: "directory_access_request".into(), + target_id: Some(request_id), + metadata_json: metadata.to_string(), + }) + .map_err(|error| WebError::internal(format!("目录权限拒绝记录写入失败: {error}")))?; + Ok(Json(json!({ + "ok": true, + "schema": "mnote.admin.settings.directory-access-request.rejected.v1", + "request": metadata, + }))) +} + fn load_receipts( state: &AppState, user_id: &str, @@ -462,6 +770,120 @@ fn load_receipts( }))) } +fn normalize_directory_request_permission(value: &str) -> String { + if value.trim() == "write" { + "write".into() + } else { + "read".into() + } +} + +fn list_directory_access_requests( + state: &AppState, + user_filter: Option<&str>, +) -> Result, WebError> { + let rows = state + .control_plane() + .list_audit_log(1000) + .map_err(|error| WebError::internal(format!("目录权限申请读取失败: {error}")))?; + let mut requests: HashMap = HashMap::new(); + for row in rows.iter().rev() { + if row.target_kind.as_str() != "directory_access_request" { + continue; + } + let Some(request_id) = row + .target_id + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + else { + continue; + }; + let metadata = + serde_json::from_str::(&row.metadata_json).unwrap_or_else(|_| json!({})); + match row.action.as_str() { + "directory_access.requested" => { + let mut value = metadata; + value["requestId"] = json!(request_id); + value["status"] = json!("pending"); + value["createdAt"] = json!(row.created_at.clone()); + requests.insert(request_id.to_string(), value); + } + "directory_access.approved" | "directory_access.rejected" => { + if let Some(value) = requests.get_mut(request_id) { + let status = metadata.get("status").and_then(Value::as_str).unwrap_or( + if row.action.ends_with("approved") { + "approved" + } else { + "rejected" + }, + ); + value["status"] = json!(status); + value["decidedAt"] = json!(row.created_at.clone()); + if let Some(decided_by) = metadata.get("decidedBy").and_then(Value::as_str) { + value["decidedBy"] = json!(decided_by); + } + if let Some(reason) = metadata.get("reason").and_then(Value::as_str) { + value["reason"] = json!(reason); + } + if let Some(grant_id) = metadata.get("grantId").and_then(Value::as_str) { + value["grantId"] = json!(grant_id); + } + } + } + _ => {} + } + } + let mut values = requests + .into_values() + .filter(|request| { + user_filter + .map(|user_id| { + request.get("userId").and_then(Value::as_str).map(str::trim) == Some(user_id) + }) + .unwrap_or(true) + }) + .collect::>(); + values.sort_by(|left, right| { + right + .get("createdAt") + .and_then(Value::as_str) + .cmp(&left.get("createdAt").and_then(Value::as_str)) + }); + Ok(values) +} + +fn find_pending_directory_access_request( + state: &AppState, + request_id: &str, +) -> Result { + let request_id = request_id.trim(); + let request = list_directory_access_requests(state, None)? + .into_iter() + .find(|request| { + request + .get("requestId") + .and_then(Value::as_str) + .map(str::trim) + == Some(request_id) + }) + .ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "directory_access_request_not_found", + "目录权限申请不存在", + ) + })?; + if request.get("status").and_then(Value::as_str) != Some("pending") { + return Err(WebError::new( + StatusCode::CONFLICT, + "directory_access_request_not_pending", + "目录权限申请已处理", + )); + } + Ok(request) +} + // ─── Admin handler implementations ────────────────────────────────────── /// `GET /api/ai-admin/settings` @@ -545,6 +967,18 @@ pub async fn admin_put_settings( })?; } } + if let Some(ref extensions) = body.pi_extensions { + for (extension_id, config) in extensions { + validate_pi_extension_config(extension_id, config).map_err(|msg| { + WebError::new( + StatusCode::UNPROCESSABLE_ENTITY, + "ai_admin_invalid_pi_extension_config", + format!("Pi extension \"{extension_id}\": {msg}"), + ) + .with_context(&context) + })?; + } + } // ── Load existing policy, merge inputs ──────────────────────────── let workspace_id = body.workspace_id.as_deref(); @@ -642,12 +1076,11 @@ pub async fn admin_get_user_settings( } else { load_policy_value(&state, &user_id, None) }; - Ok(Json(project_user_settings( - &user_id, - &global_owner, - &global_policy, - &user_policy, - ))) + let allowed_roots = load_active_directory_grants(&state, &user_id, None)?; + let mut projected = + project_user_settings(&user_id, &global_owner, &global_policy, &user_policy); + projected["allowedRoots"] = json!(allowed_roots); + Ok(Json(projected)) } pub async fn admin_put_user_settings( @@ -702,6 +1135,9 @@ pub async fn admin_put_user_settings( if let Some(value) = body.mcp_servers { policy["mcpOverrides"] = json!(value); } + if let Some(value) = body.pi_extensions { + policy["piExtensionOverrides"] = json!(value); + } let result = state .control_plane() .upsert_ai_policy(control_plane::UpsertAiPolicyInput { @@ -745,8 +1181,8 @@ const DEFAULT_PI_MODEL: &str = "omniroute/freefirst"; const LIGHTRAG_PROVIDER: &str = "lightrag"; const LIGHTRAG_PROVIDER_DESCRIPTION: &str = "MNote 知识库默认提供者(LightRAG)"; const SOURCE_OF_TRUTH: &str = "directory_grants"; -const ADMIN_ACCESS_POLICY_PATH: &str = "/admin/access-policy"; -const USER_ACCESS_POLICY_PATH: &str = "/user/access-policy"; +const ADMIN_ACCESS_POLICY_PATH: &str = "/admin/ai#ai-admin-access"; +const USER_ACCESS_POLICY_PATH: &str = "/user/ai#ai-admin-access"; fn default_mcp_transport() -> String { "stdio".into() @@ -783,16 +1219,36 @@ const MNOTE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ description: "修改授权目录内的本地文件", default_policy: "ask", }, + ToolCatalogEntry { + name: "mnote.knowledge_rag.status", + description: "查看 MNote LightRAG 知识库状态", + default_policy: "allow", + }, ToolCatalogEntry { name: "mnote.knowledge_rag.query", description: "通过 MNote LightRAG facade 查询知识库", default_policy: "allow", }, + ToolCatalogEntry { + name: "mnote.knowledge_rag.section_context", + description: "读取知识库长文档章节上下文", + default_policy: "allow", + }, + ToolCatalogEntry { + name: "mnote.knowledge_rag.open_reference", + description: "打开知识库引用来源", + default_policy: "allow", + }, ToolCatalogEntry { name: "mnote.reference.open", description: "打开知识库引用来源", default_policy: "allow", }, + ToolCatalogEntry { + name: "mnote.codex_rescue.request", + description: "请求本机 Codex 进行疑难问题自救", + default_policy: "ask", + }, ToolCatalogEntry { name: "mnote.tool_receipt.write", description: "记录工具调用回执", @@ -813,7 +1269,32 @@ fn skill_config( description: description.into(), source: source.into(), risk_level: risk_level.into(), - required_scopes: required_scopes.iter().map(|scope| (*scope).into()).collect(), + required_scopes: required_scopes + .iter() + .map(|scope| (*scope).into()) + .collect(), + } +} + +fn pi_extension_config( + name: &str, + description: &str, + source: &str, + tool_names: &[&str], + risk_level: &str, + required_scopes: &[&str], +) -> PiExtensionConfig { + PiExtensionConfig { + name: name.into(), + enabled: true, + description: description.into(), + source: source.into(), + tool_names: tool_names.iter().map(|value| (*value).into()).collect(), + risk_level: risk_level.into(), + required_scopes: required_scopes + .iter() + .map(|scope| (*scope).into()) + .collect(), } } @@ -840,7 +1321,10 @@ fn mcp_server_config( sandbox: true, description: description.into(), risk_level: risk_level.into(), - required_scopes: required_scopes.iter().map(|scope| (*scope).into()).collect(), + required_scopes: required_scopes + .iter() + .map(|scope| (*scope).into()) + .collect(), } } @@ -919,6 +1403,91 @@ fn default_skill_registry() -> HashMap { skills } +fn default_pi_extension_registry() -> HashMap { + let mut extensions = HashMap::new(); + extensions.insert( + "mnote-pi".into(), + pi_extension_config( + "MNote Pi", + "MNote 官方 Pi package,提供当前页、授权目录、LightRAG 引用和 Codex 自救工具的受控桥接。", + "mnote:core", + &[], + "high", + &["mnote:tool-bridge", "knowledge-rag:query"], + ), + ); + extensions.insert( + "pi-rust-official-permission-gate".into(), + pi_extension_config( + "Pi Rust Official Permission Gate", + "Pi Rust 官方索引 permission-gate 扩展的本地镜像,用于对危险 bash 命令做确认拦截。", + "pi-rust-official:permission-gate", + &[], + "high", + &["tool:policy"], + ), + ); + extensions.insert( + "pi-rust-official-todo".into(), + pi_extension_config( + "Pi Rust Official Todo", + "Pi Rust 官方索引 todo 扩展的本地镜像,提供轻量任务列表工具。", + "pi-rust-official:todo", + &["todo"], + "medium", + &["workflow:todo"], + ), + ); + extensions.insert( + "pi-rust-official-question".into(), + pi_extension_config( + "Pi Rust Official Question", + "Pi Rust 官方索引 question 扩展的本地镜像,用官方 question 工具向用户提问并选择答案。", + "pi-rust-official:question", + &["question"], + "low", + &["ui:ask"], + ), + ); + extensions.insert( + "pi-rust-official-questionnaire".into(), + pi_extension_config( + "Pi Rust Official Questionnaire", + "Pi Rust 官方索引 questionnaire 扩展的本地镜像,用官方 questionnaire 工具发起多问题澄清。", + "pi-rust-official:questionnaire", + &["questionnaire"], + "low", + &["ui:ask"], + ), + ); + extensions.insert( + "pi-rust-official-plan-mode".into(), + pi_extension_config( + "Pi Rust Official Plan Mode", + "Pi Rust 官方索引 plan-mode 扩展的本地镜像。默认禁用:它会注册 /todos,与官方 todo 扩展不能同时加载;MNote 默认使用后端 plan permission wrapper。", + "pi-rust-official:plan-mode", + &[], + "medium", + &["workflow:plan-review"], + ), + ); + if let Some(extension) = extensions.get_mut("pi-rust-official-plan-mode") { + extension.enabled = false; + } + extensions.insert( + "pi-rust-official-subagent".into(), + pi_extension_config( + "Pi Rust Official Subagent", + "Pi Rust 官方索引 subagent 扩展的本地镜像,提供隔离子代理委派工具。", + "pi-rust-official:subagent", + &["subagent"], + "high", + &["agent:delegate"], + ), + ); + extensions +} + fn default_mcp_server_registry() -> HashMap { let mut servers = HashMap::new(); servers.insert( @@ -1012,6 +1581,34 @@ fn effective_mcp_registry(model_policy: &Value) -> HashMap HashMap { + let mut extensions = default_pi_extension_registry(); + let configured = policy_map::(model_policy, "piExtensions"); + for (id, config) in configured { + if is_retired_pi_extension_id(&id) { + continue; + } + extensions.insert(id, config); + } + extensions +} + +fn is_retired_pi_extension_id(id: &str) -> bool { + matches!( + id, + "rpiv-ask-user-question" + | "d3ara1n-pi-ask-user" + | "pi-ask-user" + | "mnote-pi-ask-user" + | "pi-mcp-adapter" + | "pi-permission-system" + | "rpiv-todo" + | "plannotator" + | "pi-subagents" + | "pi-codex-goal" + ) +} + // ─── Internal helpers ─────────────────────────────────────────────────── /// Rejects unauthenticated / anonymous requests and returns the actor id. @@ -1142,7 +1739,16 @@ fn merge_effective_user_policy(global_policy: &Value, user_policy: &Value) -> Va *target = serde_json::to_value(default_skill_registry()).unwrap_or(json!({})); } if let Some(skills) = target.as_object_mut() { + let defaults = default_skill_registry(); for (name, enabled) in overrides { + if !skills.contains_key(name) { + if let Some(default_skill) = defaults.get(name) { + skills.insert( + name.clone(), + serde_json::to_value(default_skill).unwrap_or(json!({})), + ); + } + } if let Some(skill) = skills.get_mut(name) { skill["enabled"] = json!(enabled.as_bool().unwrap_or(false)); } @@ -1154,18 +1760,60 @@ fn merge_effective_user_policy(global_policy: &Value, user_policy: &Value) -> Va .as_object_mut() .expect("effective policy object") .entry("mcpServers") - .or_insert_with(|| serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({}))); + .or_insert_with(|| { + serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({})) + }); if !target.is_object() { *target = serde_json::to_value(default_mcp_server_registry()).unwrap_or(json!({})); } if let Some(servers) = target.as_object_mut() { + let defaults = default_mcp_server_registry(); for (name, enabled) in overrides { + if !servers.contains_key(name) { + if let Some(default_server) = defaults.get(name) { + servers.insert( + name.clone(), + serde_json::to_value(default_server).unwrap_or(json!({})), + ); + } + } if let Some(server) = servers.get_mut(name) { server["enabled"] = json!(enabled.as_bool().unwrap_or(false)); } } } } + if let Some(overrides) = user_policy + .get("piExtensionOverrides") + .and_then(Value::as_object) + { + let target = merged + .as_object_mut() + .expect("effective policy object") + .entry("piExtensions") + .or_insert_with(|| { + serde_json::to_value(default_pi_extension_registry()).unwrap_or(json!({})) + }); + if !target.is_object() { + *target = serde_json::to_value(default_pi_extension_registry()).unwrap_or(json!({})); + } + if let Some(extensions) = target.as_object_mut() { + let defaults = default_pi_extension_registry(); + for (name, enabled) in overrides { + if !extensions.contains_key(name) { + if let Some(default_extension) = defaults.get(name) { + extensions.insert( + name.clone(), + serde_json::to_value(default_extension).unwrap_or(json!({})), + ); + } + } + if let Some(extension) = extensions.get_mut(name) { + extension["enabled"] = json!(enabled.as_bool().unwrap_or(false)); + } + } + } + } merged } @@ -1210,6 +1858,258 @@ fn effective_models(model_policy: &Value, default_model: &str) -> Vec, value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + if let Some((provider, model_id)) = trimmed.split_once('/') { + let provider = provider.trim(); + let model_id = model_id.trim(); + if provider.is_empty() || model_id.is_empty() { + return None; + } + return Some(format!("{provider}/{model_id}")); + } + let provider = default_provider + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("omniroute"); + Some(format!("{provider}/{trimmed}")) +} + +fn pi_mcp_extension_bridge_enabled() -> bool { + std::env::var("MNOTE_PAGE_AI_PI_MCP_EXTENSION") + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + .map(|value| !matches!(value.as_str(), "0" | "false" | "off" | "disabled" | "none")) + .unwrap_or(true) +} + +pub(crate) fn load_effective_ai_runtime_policy( + state: &AppState, + actor_id: &str, + workspace_id: Option<&str>, +) -> EffectiveAiRuntimePolicy { + let (model_policy, _) = load_effective_model_policy_and_quota(state, actor_id, workspace_id); + let default_model = effective_default_model(&model_policy); + let default_provider = normalize_model_ref(None, &default_model) + .and_then(|value| { + value + .split_once('/') + .map(|(provider, _)| provider.to_string()) + }) + .unwrap_or_else(|| "omniroute".into()); + let mut allowed_models = effective_models(&model_policy, &default_model) + .into_iter() + .filter_map(|entry| normalize_model_ref(Some(&default_provider), &entry.id)) + .collect::>(); + allowed_models.sort(); + allowed_models.dedup(); + + let skill_registry = effective_skill_registry(&model_policy); + let mut enabled_skills = skill_registry + .iter() + .filter_map(|(id, skill)| skill.enabled.then_some(id.clone())) + .collect::>(); + enabled_skills.sort(); + let mut enabled_skill_sources = skill_registry + .into_iter() + .filter_map(|(_, skill)| { + let source = skill.source.trim(); + (skill.enabled && !source.is_empty() && !source.starts_with("mcp://")) + .then(|| source.to_string()) + }) + .collect::>(); + enabled_skill_sources.sort(); + enabled_skill_sources.dedup(); + + let mcp_registry = effective_mcp_registry(&model_policy); + let mcp_bridge_enabled = pi_mcp_extension_bridge_enabled(); + let mut enabled_mcp_servers = mcp_registry + .iter() + .filter_map(|(id, server)| { + (mcp_bridge_enabled && server.enabled && server.facade_only && server.sandbox) + .then_some(id.clone()) + }) + .collect::>(); + enabled_mcp_servers.sort(); + let mcp_servers = if mcp_bridge_enabled { + Value::Object( + mcp_registry + .into_iter() + .filter_map(|(id, server)| { + mcp_server_runtime_config(&server).map(|config| (id, config)) + }) + .collect(), + ) + } else { + json!({}) + }; + let pi_extension_registry = effective_pi_extension_registry(&model_policy); + let mut enabled_pi_extensions = pi_extension_registry + .iter() + .filter_map(|(id, extension)| extension.enabled.then_some(id.clone())) + .collect::>(); + enabled_pi_extensions.sort(); + let mut enabled_pi_extension_sources = pi_extension_registry + .values() + .filter_map(|extension| { + let source = extension.source.trim(); + (extension.enabled && !source.is_empty()).then(|| source.to_string()) + }) + .collect::>(); + enabled_pi_extension_sources.sort(); + enabled_pi_extension_sources.dedup(); + let mut pi_extension_tool_names = pi_extension_registry + .values() + .filter(|extension| extension.enabled) + .flat_map(|extension| extension.tool_names.iter()) + .map(|tool_name| tool_name.trim()) + .filter(|tool_name| !tool_name.is_empty()) + .map(str::to_string) + .collect::>(); + pi_extension_tool_names.sort(); + pi_extension_tool_names.dedup(); + + let tool_policies = policy_string_map(&model_policy, "tools"); + let effective_tools = effective_tool_catalog(&tool_policies); + let mut mnote_tool_names = effective_tools + .iter() + .filter_map(|tool| (tool.default_policy != "deny").then_some(tool.name.to_string())) + .collect::>(); + mnote_tool_names.sort(); + let mnote_tool_policies = effective_tools + .iter() + .map(|tool| (tool.name.to_string(), tool.default_policy.to_string())) + .collect::>(); + + EffectiveAiRuntimePolicy { + default_model: normalize_model_ref(None, &default_model).unwrap_or(default_model), + allowed_models, + enabled_skills, + enabled_skill_sources, + enabled_mcp_servers, + enabled_pi_extensions, + enabled_pi_extension_sources, + pi_extension_tool_names, + pi_extensions: pi_extension_registry, + mcp_bridge: if mcp_servers + .as_object() + .is_some_and(|servers| !servers.is_empty()) + { + "pi-rust-sync-client".into() + } else { + "disabled".into() + }, + mcp_servers, + mnote_tool_names, + mnote_tool_policies, + native_mcp_supported: false, + } +} + +fn mcp_server_runtime_config(server: &McpServerConfig) -> Option { + if !(server.enabled && server.facade_only && server.sandbox) { + return None; + } + let mut config = serde_json::Map::new(); + config.insert("transport".into(), json!(server.transport)); + config.insert("lifecycle".into(), json!("lazy")); + config.insert("requestTimeoutMs".into(), json!(30_000)); + match server.transport.as_str() { + "stdio" => { + let command_parts = split_command_parts(&server.command); + let command = command_parts.first()?.clone(); + let args = command_parts.into_iter().skip(1).collect::>(); + config.insert("command".into(), json!(command)); + if !args.is_empty() { + config.insert("args".into(), json!(args)); + } + } + "sse" | "streamable-http" => { + if server.url.trim().is_empty() { + return None; + } + config.insert("url".into(), json!(server.url.trim())); + let env_refs = env_secret_refs(&server.secret_refs); + if !env_refs.is_empty() { + let mut headers = serde_json::Map::new(); + headers.insert( + "Authorization".into(), + json!(format!("Bearer ${{{}}}", env_refs[0])), + ); + config.insert("headers".into(), Value::Object(headers)); + } + } + _ => return None, + } + let env_refs = env_secret_refs(&server.secret_refs); + if !env_refs.is_empty() { + config.insert( + "env".into(), + Value::Object( + env_refs + .into_iter() + .map(|name| { + let value = format!("${{{name}}}"); + (name, json!(value)) + }) + .collect(), + ), + ); + } + Some(Value::Object(config)) +} + +fn env_secret_refs(secret_refs: &[String]) -> Vec { + secret_refs + .iter() + .filter_map(|value| value.trim().strip_prefix("env://").map(str::trim)) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +fn split_command_parts(command: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut quote: Option = None; + let mut escaped = false; + for ch in command.chars() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if let Some(active_quote) = quote { + if ch == active_quote { + quote = None; + } else { + current.push(ch); + } + continue; + } + if ch == '"' || ch == '\'' { + quote = Some(ch); + } else if ch.is_whitespace() { + if !current.is_empty() { + parts.push(std::mem::take(&mut current)); + } + } else { + current.push(ch); + } + } + if !current.is_empty() { + parts.push(current); + } + parts +} + fn policy_map(model_policy: &Value, key: &str) -> HashMap where T: for<'de> Deserialize<'de>, @@ -1414,6 +2314,19 @@ fn validate_user_policy_body(body: &UserAiPolicyBody, global_policy: &Value) -> } } } + let global_pi_extensions = effective_pi_extension_registry(global_policy); + if let Some(extensions) = &body.pi_extensions { + for (name, enabled) in extensions { + if *enabled + && !global_pi_extensions + .get(name) + .map(|extension| extension.enabled) + .unwrap_or(false) + { + return Err(format!("Pi 扩展 {name} 未被管理员全局启用")); + } + } + } Ok(()) } @@ -1471,6 +2384,27 @@ fn project_user_settings( }) }) .collect::>(); + let pi_extensions = effective_pi_extension_registry(global_policy) + .into_iter() + .map(|(id, extension)| { + let override_value = user_policy + .get("piExtensionOverrides") + .and_then(|value| value.get(&id)) + .and_then(Value::as_bool); + json!({ + "id": id, + "name": extension.name, + "description": extension.description, + "source": extension.source, + "toolNames": extension.tool_names, + "riskLevel": extension.risk_level, + "requiredScopes": extension.required_scopes, + "globallyEnabled": extension.enabled, + "enabled": override_value.unwrap_or(extension.enabled), + "hasOverride": override_value.is_some(), + }) + }) + .collect::>(); let tools = MNOTE_TOOL_CATALOG .iter() .map(|tool| { @@ -1500,6 +2434,7 @@ fn project_user_settings( "tools": tools, "skills": skills, "mcpServers": mcp_servers, + "piExtensions": pi_extensions, }) } @@ -1520,6 +2455,9 @@ fn describe_user_updated_fields(body: &UserAiPolicyBody) -> Vec<&'static str> { if body.mcp_servers.is_some() { fields.push("mcpOverrides"); } + if body.pi_extensions.is_some() { + fields.push("piExtensionOverrides"); + } fields } @@ -1570,6 +2508,34 @@ fn validate_mcp_server_config(config: &McpServerConfig) -> Result<(), String> { Ok(()) } +fn validate_pi_extension_config(id: &str, config: &PiExtensionConfig) -> Result<(), String> { + let normalized_id = id.trim(); + let source = config.source.trim(); + if normalized_id.is_empty() { + return Err("扩展 ID 不能为空".into()); + } + if normalized_id == "pi-landstrip" || source.contains("pi-landstrip") { + return Err("OS 沙盒扩展 pi-landstrip 不在默认接入范围内".into()); + } + if source.is_empty() + || !(source.starts_with("npm:") + || source.starts_with("mnote:") + || source.starts_with("pi-rust-official:")) + { + return Err( + "Pi 扩展来源必须使用 npm:package、mnote: 内置扩展或 pi-rust-official: 官方扩展形式" + .into(), + ); + } + if config.name.trim().is_empty() { + return Err("扩展名称不能为空".into()); + } + if !matches!(config.risk_level.as_str(), "low" | "medium" | "high") { + return Err("riskLevel 只允许 low、medium 或 high".into()); + } + Ok(()) +} + /// Merges the client-provided body into the existing policy (if any). /// Returns `(model_policy_json, quota_json)` strings suitable for /// `UpsertAiPolicyInput`. @@ -1630,17 +2596,13 @@ fn merge_policy_with_existing( model_policy["tools"] = Value::Object(merged); } - // ── Merge skills registry ─────────────────────────────────────── + // ── Replace skills registry ───────────────────────────────────── + // + // The admin settings UI edits this registry as the desired full + // state. Re-merging deleted rows back from the persisted policy + // makes "删除" a no-op, so presence of this section means replace. if let Some(ref skills) = body.skills { - let current = model_policy - .get("skills") - .and_then(|v| v.as_object()) - .map(|m| m.clone()) - .unwrap_or_default(); let mut merged = serde_json::Map::new(); - for (k, v) in current { - merged.insert(k, v.clone()); - } for (k, v) in skills { let val = serde_json::to_value(v).unwrap_or(json!({})); merged.insert(k.clone(), val); @@ -1648,17 +2610,12 @@ fn merge_policy_with_existing( model_policy["skills"] = Value::Object(merged); } - // ── Merge MCP servers map ─────────────────────────────────────── + // ── Replace MCP servers map ───────────────────────────────────── + // + // Same contract as skills: admin sends the full editable registry, + // including deletions. if let Some(ref servers) = body.mcp_servers { - let current = model_policy - .get("mcpServers") - .and_then(|v| v.as_object()) - .map(|m| m.clone()) - .unwrap_or_default(); let mut merged = serde_json::Map::new(); - for (k, v) in current { - merged.insert(k, v.clone()); - } for (k, v) in servers { let val = serde_json::to_value(v).unwrap_or(json!({})); merged.insert(k.clone(), val); @@ -1666,6 +2623,15 @@ fn merge_policy_with_existing( model_policy["mcpServers"] = Value::Object(merged); } + if let Some(ref extensions) = body.pi_extensions { + let mut merged = serde_json::Map::new(); + for (k, v) in extensions { + let val = serde_json::to_value(v).unwrap_or(json!({})); + merged.insert(k.clone(), val); + } + model_policy["piExtensions"] = Value::Object(merged); + } + let model_policy_json = serde_json::to_string(&model_policy).unwrap_or_else(|_| "{}".into()); // ── Merge quota ───────────────────────────────────────────────── @@ -1748,6 +2714,7 @@ fn project_admin_settings( let skills = effective_skill_registry(model_policy); let mcp_servers = effective_mcp_registry(model_policy); + let pi_extensions = effective_pi_extension_registry(model_policy); let revision = record.map(|value| value.revision).unwrap_or(0); let updated_at = record @@ -1765,6 +2732,7 @@ fn project_admin_settings( tools, skills, mcp_servers, + pi_extensions, quota: quota.clone(), revision, updated_at, @@ -1797,6 +2765,9 @@ fn describe_updated_fields(body: &UpsertAiPolicyBody) -> Vec<&'static str> { if body.mcp_servers.is_some() { fields.push("mcpServers"); } + if body.pi_extensions.is_some() { + fields.push("piExtensions"); + } if body.quota.is_some() { fields.push("quota"); } @@ -1887,6 +2858,7 @@ mod tests { tool_catalog: vec![], skills: vec![], mcp_servers: vec![], + pi_extensions: vec![], lightrag_provider: LightRagProviderRef { provider: "test", description: "test provider", @@ -2007,6 +2979,7 @@ mod tests { tools: None, skills: None, mcp_servers: None, + pi_extensions: None, quota: None, }; let (model_json, quota_json) = merge_policy_with_existing(None, &body); @@ -2045,6 +3018,7 @@ mod tests { tools: None, skills: None, mcp_servers: None, + pi_extensions: None, quota: Some(json!({"tokens": 2000})), }; let (model_json, quota_json) = merge_policy_with_existing(Some(&existing_rec), &body); @@ -2067,6 +3041,7 @@ mod tests { tools: Some([("mnote.local_file.read".into(), "deny".into())].into()), skills: None, mcp_servers: None, + pi_extensions: None, quota: None, }; let (model_json, _) = merge_policy_with_existing(None, &body); @@ -2117,6 +3092,21 @@ mod tests { )] .into(), ), + pi_extensions: Some( + [( + "pi-rust-official-todo".into(), + PiExtensionConfig { + name: "Pi Rust Official Todo".into(), + enabled: true, + description: "Todo tools".into(), + source: "pi-rust-official:todo".into(), + tool_names: vec!["todo".into()], + risk_level: "medium".into(), + required_scopes: vec!["workflow:todo".into()], + }, + )] + .into(), + ), quota: None, }; let (model_json, _) = merge_policy_with_existing(None, &body); @@ -2126,6 +3116,94 @@ mod tests { assert_eq!(parsed["mcpServers"]["fs-mcp"]["url"], "http://mcp:9090"); assert_eq!(parsed["mcpServers"]["fs-mcp"]["facadeOnly"], true); assert_eq!(parsed["mcpServers"]["fs-mcp"]["sandbox"], true); + assert_eq!( + parsed["piExtensions"]["pi-rust-official-todo"]["source"], + "pi-rust-official:todo" + ); + } + + #[test] + fn merge_policy_skills_and_mcp_replace_existing_registries() { + let existing = control_plane::AiPolicyRecord { + id: "policy_1".into(), + user_id: Some("admin".into()), + workspace_id: None, + allowed_roots_json: "[]".into(), + model_policy_json: json!({ + "skills": { + "stale-skill": { + "name": "Stale Skill", + "enabled": true, + "description": "", + "source": "", + "riskLevel": "low", + "requiredScopes": [] + }, + "keep-skill": { + "name": "Keep Skill", + "enabled": false, + "description": "", + "source": "/tmp/keep/SKILL.md", + "riskLevel": "low", + "requiredScopes": [] + } + }, + "mcpServers": { + "stale-mcp": { + "name": "Stale MCP", + "enabled": true, + "url": "", + "transport": "stdio", + "command": "old-mcp", + "networkPolicy": "allow-local", + "secretRefs": [], + "facadeOnly": true, + "sandbox": true, + "description": "", + "riskLevel": "medium", + "requiredScopes": [] + } + } + }) + .to_string(), + quota_json: "{}".into(), + created_at: String::new(), + updated_at: String::new(), + revision: 1, + }; + let body = UpsertAiPolicyBody { + default_model: None, + allowed_models: None, + workspace_id: None, + providers: None, + build_plan_task: None, + tools: None, + skills: Some( + [( + "keep-skill".into(), + SkillConfig { + name: "Keep Skill".into(), + enabled: true, + description: String::new(), + source: "/tmp/keep/SKILL.md".into(), + risk_level: "low".into(), + required_scopes: vec![], + }, + )] + .into(), + ), + mcp_servers: Some(HashMap::new()), + pi_extensions: Some(HashMap::new()), + quota: None, + }; + + let (model_json, _) = merge_policy_with_existing(Some(&existing), &body); + let parsed: Value = serde_json::from_str(&model_json).unwrap(); + + assert!(parsed["skills"].get("stale-skill").is_none()); + assert_eq!(parsed["skills"]["keep-skill"]["enabled"], true); + assert!(parsed["mcpServers"].as_object().unwrap().is_empty()); + assert!(parsed["piExtensions"].as_object().unwrap().is_empty()); } // ─── Admin projection ──────────────────────────────────────────────── @@ -2148,6 +3226,36 @@ mod tests { assert!(response.mcp_servers.contains_key("searxng")); assert!(response.mcp_servers.contains_key("mempalace")); assert!(response.mcp_servers.contains_key("codegraph")); + assert!(response.pi_extensions.contains_key("mnote-pi")); + assert!(!response.pi_extensions.contains_key("pi-mcp-adapter")); + assert!(!response.pi_extensions.contains_key("pi-permission-system")); + assert!(!response.pi_extensions.contains_key("rpiv-todo")); + assert!(!response + .pi_extensions + .contains_key("rpiv-ask-user-question")); + assert!(!response.pi_extensions.contains_key("d3ara1n-pi-ask-user")); + assert!(response + .pi_extensions + .contains_key("pi-rust-official-question")); + assert!(response + .pi_extensions + .contains_key("pi-rust-official-questionnaire")); + assert!(!response.pi_extensions.contains_key("mnote-pi-ask-user")); + assert!(!response.pi_extensions.contains_key("pi-ask-user")); + assert!(response.pi_extensions.contains_key("pi-rust-official-todo")); + assert!(response + .pi_extensions + .contains_key("pi-rust-official-permission-gate")); + assert!(response + .pi_extensions + .contains_key("pi-rust-official-plan-mode")); + assert!(response + .pi_extensions + .contains_key("pi-rust-official-subagent")); + assert!(!response.pi_extensions.contains_key("plannotator")); + assert!(!response.pi_extensions.contains_key("pi-subagents")); + assert!(!response.pi_extensions.contains_key("pi-codex-goal")); + assert!(!response.pi_extensions.contains_key("pi-landstrip")); } #[test] @@ -2184,6 +3292,14 @@ mod tests { "facadeOnly": true, "sandbox": true } + }, + "piExtensions": { + "pi-rust-official-todo": { + "name": "Pi Rust Official Todo", + "enabled": true, + "source": "pi-rust-official:todo", + "toolNames": ["todo"] + } } }); let quota = json!({"tokens": 5000}); @@ -2197,6 +3313,7 @@ mod tests { ); assert!(response.skills.contains_key("img-skill")); assert!(response.mcp_servers.contains_key("fs-mcp")); + assert!(response.pi_extensions.contains_key("pi-rust-official-todo")); assert_eq!(response.quota["tokens"], 5000); } @@ -2211,6 +3328,7 @@ mod tests { tools: None, skills: None, mcp_servers: None, + pi_extensions: None, quota: None, }; let fields = describe_updated_fields(&body); @@ -2231,10 +3349,11 @@ mod tests { tools: Some(HashMap::new()), skills: Some(HashMap::new()), mcp_servers: Some(HashMap::new()), + pi_extensions: Some(HashMap::new()), quota: Some(json!({})), }; let fields = describe_updated_fields(&body); - assert_eq!(fields.len(), 8); + assert_eq!(fields.len(), 9); } #[test] @@ -2247,19 +3366,33 @@ mod tests { }, "mcpServers": { "docs": {"name": "Docs", "enabled": true, "facadeOnly": true, "sandbox": true} + }, + "piExtensions": { + "pi-rust-official-subagent": {"name": "Pi Rust Official Subagent", "enabled": true} } }); let user = json!({ "defaultModel": "omniroute/fast", "allowedModels": ["omniroute/fast"], - "skillOverrides": {"search": false}, - "mcpOverrides": {"docs": false} + "skillOverrides": {"search": false, "context7": false}, + "mcpOverrides": {"docs": false, "context7": false}, + "piExtensionOverrides": {"pi-rust-official-subagent": false, "pi-rust-official-todo": false} }); let merged = merge_effective_user_policy(&global, &user); assert_eq!(merged["defaultModel"], "omniroute/fast"); assert_eq!(merged["allowedModels"], json!(["omniroute/fast"])); assert_eq!(merged["skills"]["search"]["enabled"], false); + assert_eq!(merged["skills"]["context7"]["enabled"], false); assert_eq!(merged["mcpServers"]["docs"]["enabled"], false); + assert_eq!(merged["mcpServers"]["context7"]["enabled"], false); + assert_eq!( + merged["piExtensions"]["pi-rust-official-subagent"]["enabled"], + false + ); + assert_eq!( + merged["piExtensions"]["pi-rust-official-todo"]["enabled"], + false + ); } #[test] @@ -2276,6 +3409,7 @@ mod tests { tools: Some([("mnote.local_file.patch".into(), "allow".into())].into()), skills: Some([("search".into(), true)].into()), mcp_servers: Some([("docs".into(), true)].into()), + pi_extensions: Some([("pi-rust-official-subagent".into(), true)].into()), }; assert!(validate_user_policy_body(&body, &global).is_err()); } @@ -2292,6 +3426,7 @@ mod tests { tools: Some([("mnote.local_file.patch".into(), "deny".into())].into()), skills: None, mcp_servers: None, + pi_extensions: None, }; assert!(validate_user_policy_body(&body, &global).is_ok()); } diff --git a/rust/crates/mnote-web/src/routes/dev_seed.rs b/rust/crates/mnote-web/src/routes/dev_seed.rs index f8bd8b86..763248a1 100644 --- a/rust/crates/mnote-web/src/routes/dev_seed.rs +++ b/rust/crates/mnote-web/src/routes/dev_seed.rs @@ -5,7 +5,7 @@ use axum::http::StatusCode; use axum::Json; use control_plane::{ AppendAiRuntimeEventInput, CreatePasswordIdentityInput, DirectoryGrantInput, - UpsertAiRuntimeRunInput, UpsertUserInput, UpsertWorkspaceInput, + UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertUserInput, UpsertWorkspaceInput, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -70,6 +70,25 @@ enum DevSeedOperation { #[serde(default)] events: Vec, }, + SeedAiPolicy { + #[serde(default)] + id: Option, + #[serde(default)] + #[serde(alias = "userId")] + user_id: Option, + #[serde(default)] + #[serde(alias = "workspaceId")] + workspace_id: Option, + #[serde(default = "empty_json_array")] + #[serde(alias = "allowedRootsJson")] + allowed_roots_json: Value, + #[serde(default = "empty_json_object")] + #[serde(alias = "modelPolicyJson")] + model_policy_json: Value, + #[serde(default = "empty_json_object")] + #[serde(alias = "quotaJson")] + quota_json: Value, + }, ClearRuntimeEvents { user_id: String, run_id: String, @@ -141,6 +160,10 @@ fn empty_json_object() -> Value { json!({}) } +fn empty_json_array() -> Value { + json!([]) +} + fn default_dev_seed_limit() -> usize { 200 } @@ -305,6 +328,30 @@ fn apply_seed_operation(state: &AppState, operation: DevSeedOperation) -> Result "events": event_records, })) } + DevSeedOperation::SeedAiPolicy { + id, + user_id, + workspace_id, + allowed_roots_json, + model_policy_json, + quota_json, + } => { + let policy = state + .control_plane() + .upsert_ai_policy(UpsertAiPolicyInput { + id, + user_id, + workspace_id, + allowed_roots_json: allowed_roots_json.to_string(), + model_policy_json: model_policy_json.to_string(), + quota_json: quota_json.to_string(), + }) + .map_err(dev_seed_error)?; + Ok(json!({ + "kind": "seedAiPolicy", + "policy": policy, + })) + } DevSeedOperation::ClearRuntimeEvents { user_id, run_id } => { let deleted = state .control_plane() @@ -592,4 +639,75 @@ mod tests { assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix"); assert_eq!(payload["results"][0]["run"]["status"], "running"); } + + #[tokio::test] + async fn dev_seed_ai_policy_writes_to_control_plane() { + let router = test_router(true); + + let (status, payload) = send_seed( + router.clone(), + json!({ + "seeds": [ + { + "kind": "setupWorkspace", + "user_id": "dev-seed-policy-user", + "workspace_id": "dev-seed-policy-ws", + "workspace_name": "策略测试空间", + "root_uri": "file:///tmp/dev-seed-policy", + "root_path": "/tmp/dev-seed-policy" + }, + { + "kind": "seedAiPolicy", + "id": "dev-seed-ai-policy", + "userId": "dev-seed-policy-user", + "workspaceId": "dev-seed-policy-ws", + "allowedRootsJson": [{ "rootUri": "file:///tmp/dev-seed-policy" }], + "modelPolicyJson": { + "defaultModel": "omniroute/pi-fast", + "allowedModels": ["omniroute/pi-fast"], + "skills": { + "smoke-skill": { + "name": "Smoke Skill", + "enabled": true, + "description": "seeded by dev fixture", + "source": "/tmp/mnote-pi-smoke-skill/SKILL.md", + "riskLevel": "low", + "requiredScopes": [] + } + }, + "mcpServers": { + "smoke-mcp": { + "name": "Smoke MCP", + "enabled": true, + "transport": "stdio", + "command": "node /tmp/mnote-pi-smoke-mcp/server.mjs", + "url": "", + "networkPolicy": "deny-all", + "secretRefs": [], + "facadeOnly": true, + "sandbox": true, + "description": "seeded by dev fixture", + "riskLevel": "low", + "requiredScopes": [] + } + } + }, + "quotaJson": { "daily": 10 } + } + ] + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "seedAiPolicy 应成功: {payload}"); + assert_eq!(payload["results"][1]["kind"], "seedAiPolicy"); + assert_eq!(payload["results"][1]["policy"]["id"], "dev-seed-ai-policy"); + let model_policy: Value = serde_json::from_str( + payload["results"][1]["policy"]["model_policy_json"] + .as_str() + .unwrap(), + ) + .expect("model policy json"); + assert_eq!(model_policy["skills"]["smoke-skill"]["enabled"], true); + assert_eq!(model_policy["mcpServers"]["smoke-mcp"]["enabled"], true); + } } diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index 61e2a9ab..9f70dc4e 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -3,11 +3,10 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::provider_identity_sync::sync_provider_identities; use crate::routes::local_folder_source::{ - control_plane_status_display, create_default_local_workspace_for_actor, - ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context, - load_local_folder_file_tree_children_snapshot, load_local_folder_file_tree_snapshot, - load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot, - load_local_trash_entries, + create_default_local_workspace_for_actor, ensure_local_workspace_read_access_with_state, + is_local_access_policy_admin_context, load_local_folder_file_tree_children_snapshot, + load_local_folder_file_tree_snapshot, load_local_folder_page_tree_scope_snapshot, + load_local_folder_page_tree_snapshot, load_local_trash_entries, }; use crate::routes::snapshot_support::load_sidebar_dataset; use crate::routes::web_shell::{ @@ -197,28 +196,11 @@ pub async fn admin_access_policy_entry( ) .with_context(&context)); } - let workspace_name = default_workspace_name_for_context(&state, &context); - let share_grants_path = control_plane_status_display(); - let content = crate::ssr::render_view(leptos::view! { - - }); - let mut response = Html(format!( - r#" - - - - 授权管理 - - - - {} - -"#, - crate::ssr::MNOTE_CSS, - escape_html(context.auth.actor_id.as_str()), - content - )) - .into_response(); + let mut response = Response::builder() + .status(StatusCode::SEE_OTHER) + .header(header::LOCATION, "/admin/ai#ai-admin-access") + .body(Body::empty()) + .map_err(|error| WebError::internal(format!("授权管理跳转响应构造失败: {error}")))?; stamp_gateway_headers(response.headers_mut(), false); Ok(response) } @@ -236,27 +218,11 @@ pub async fn user_access_policy_entry( stamp_gateway_headers(response.headers_mut(), false); return Ok(response); } - let workspace_name = default_workspace_name_for_context(&state, &context); - let content = crate::ssr::render_view(leptos::view! { - - }); - let mut response = Html(format!( - r#" - - - - 授权管理 - - - - {} - -"#, - crate::ssr::MNOTE_CSS, - escape_html(context.auth.actor_id.as_str()), - content - )) - .into_response(); + let mut response = Response::builder() + .status(StatusCode::SEE_OTHER) + .header(header::LOCATION, "/user/ai#ai-admin-access") + .body(Body::empty()) + .map_err(|error| WebError::internal(format!("授权管理跳转响应构造失败: {error}")))?; stamp_gateway_headers(response.headers_mut(), false); Ok(response) } @@ -285,6 +251,26 @@ pub async fn admin_ai_entry( ai_management_response(&state, &context, true) } +pub async fn settings_entry( + State(state): State, + Extension(context): Extension, +) -> Result { + 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, + is_local_access_policy_admin_context(&context), + ) +} + pub async fn user_ai_entry( State(state): State, Extension(context): Extension, @@ -313,7 +299,7 @@ fn ai_management_response( is_admin={is_admin} /> }); - let title = if is_admin { "AI 管理" } else { "AI 设置" }; + let title = "MNote 设置"; let shell = if is_admin { "admin-ai" } else { @@ -3144,10 +3130,11 @@ mod tests { .expect("body"); let admin_html = String::from_utf8(admin_body.to_vec()).expect("utf8"); assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); + assert!(!admin_html.contains(r#"data-testid="mnote-account-access-policy""#)); assert!(admin_html.contains(r#"data-mnote-action="open-account-menu""#)); assert!(admin_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js")); - assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#)); - assert!(admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#)); + assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#)); + assert!(!admin_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#)); let user_response = app_with_query_fixtures("http://127.0.0.1:3100".into(), false, None, None) @@ -3168,10 +3155,11 @@ mod tests { .expect("body"); let user_html = String::from_utf8(user_body.to_vec()).expect("utf8"); assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-entry""#)); + assert!(!user_html.contains(r#"data-testid="mnote-account-access-policy""#)); assert!(user_html.contains(r#"data-mnote-action="open-account-menu""#)); assert!(user_html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js")); - assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#)); - assert!(user_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#)); + assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-template-admin""#)); + assert!(!user_html.contains(r#"data-testid="mnote-admin-access-policy-template-user""#)); } #[tokio::test] @@ -3269,18 +3257,11 @@ mod tests { .await .expect("admin response"); - assert_eq!(admin_response.status(), StatusCode::OK); - let body = to_bytes(admin_response.into_body(), usize::MAX) - .await - .expect("body"); - let html = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(html.contains(r#"data-testid="mnote-admin-access-policy-page""#)); - assert!(html.contains("文件夹授权")); - assert!(html.contains("管理员可以授权任意本地文件夹")); - assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#)); - assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#)); - assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#)); - assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#)); + assert_eq!(admin_response.status(), StatusCode::SEE_OTHER); + assert_eq!( + admin_response.headers().get(header::LOCATION).unwrap(), + "/admin/ai#ai-admin-access" + ); } #[tokio::test] @@ -3297,19 +3278,11 @@ mod tests { .await .expect("user access policy response"); - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let html = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(html.contains(r#"data-mnote-shell="user-access-policy""#)); - assert!(html.contains("文件夹授权")); - assert!(html.contains("普通用户只能授权自己空间下的文件夹")); - assert!(html.contains(r#"data-testid="mnote-admin-create-share-grant-submit""#)); - assert!(!html.contains("仅管理员可见")); - assert!(!html.contains(r#"data-testid="mnote-admin-validate-root-submit""#)); - assert!(!html.contains(r#"data-testid="mnote-admin-create-grant-submit""#)); - assert!(!html.contains(r#"data-testid="mnote-admin-delete-grant-submit""#)); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "/user/ai#ai-admin-access" + ); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/knowledge_rag.rs b/rust/crates/mnote-web/src/routes/knowledge_rag.rs index 8f6dd946..510e44af 100644 --- a/rust/crates/mnote-web/src/routes/knowledge_rag.rs +++ b/rust/crates/mnote-web/src/routes/knowledge_rag.rs @@ -1512,6 +1512,7 @@ pub async fn query_rag( "mode": mode.clone(), "top_k": body.top_k, "chunk_top_k": body.chunk_top_k, + "enable_rerank": false, "include_references": true, "include_chunk_content": body.include_chunk_content.unwrap_or(true), })), @@ -1650,6 +1651,7 @@ pub async fn search( "mode": search_mode.clone(), "top_k": body.top_k, "chunk_top_k": body.chunk_top_k, + "enable_rerank": false, "include_references": true, "include_chunk_content": body.include_chunk_content.unwrap_or(true), })), diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 300a3b1b..288222d0 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -56,6 +56,11 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock< Mutex>, > = OnceLock::new(); +#[cfg(not(test))] +static LOCAL_SEARCH_INDEX_CONTROL_PLANE: OnceLock< + std::sync::Arc, +> = OnceLock::new(); + fn local_page_tree_snapshot_cache( ) -> &'static Mutex> { LOCAL_PAGE_TREE_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(BTreeMap::new())) @@ -3363,13 +3368,26 @@ fn save_local_markdown_page_inner( } fn refresh_local_search_index_best_effort(root: &Path, root_uri: &str, workspace_id: &str) { - let control_plane = open_control_plane_store(); - let _ = local_search_index::refresh_local_search_index_for_change_with_store( - control_plane.as_ref(), - root, - root_uri, - workspace_id, - ); + #[cfg(test)] + { + let control_plane = open_control_plane_store(); + let _ = local_search_index::refresh_local_search_index_for_change_with_store( + control_plane.as_ref(), + root, + root_uri, + workspace_id, + ); + } + #[cfg(not(test))] + { + let control_plane = LOCAL_SEARCH_INDEX_CONTROL_PLANE.get_or_init(open_control_plane_store); + let _ = local_search_index::refresh_local_search_index_for_change_with_store( + control_plane.as_ref(), + root, + root_uri, + workspace_id, + ); + } } fn local_markdown_conflict_error( @@ -4605,13 +4623,20 @@ fn add_local_file_operation_contract(root_uri: &str, action: &str, mut result: V let previous_relative_path = local_tree_previous_relative_path(&result); let document_id = local_tree_result_string(&result, "documentId") .or_else(|| local_tree_result_string(&result, "id")); + let resource_kind = local_tree_result_string(&result, "resourceKind"); let previous_document_id = local_tree_result_string(&result, "previousDocumentId").or_else(|| { - previous_relative_path - .as_deref() - .map(local_markdown_path_page_id) + if matches!( + resource_kind.as_deref(), + Some("markdown") | Some("markdown_bundle") + ) { + document_id.clone() + } else { + previous_relative_path + .as_deref() + .map(local_markdown_path_page_id) + } }); - let resource_kind = local_tree_result_string(&result, "resourceKind"); let affected_parents = add_local_tree_command_affected_parents(&result, &resolved_action); let resource = relative_path.as_deref().and_then(|path| { local_file_operation_resource(path, document_id.clone(), resource_kind.as_deref()) @@ -5529,6 +5554,15 @@ fn move_local_directory( } fn trash_local_entry(root: &Path, entry_id: &str) -> Result { + if let Some(idempotent) = idempotent_deleted_local_directory(root, entry_id)? { + return Ok(idempotent); + } + if let Some(idempotent) = idempotent_deleted_local_markdown_bundle_directory(root, entry_id)? { + return Ok(idempotent); + } + if let Some(idempotent) = idempotent_deleted_local_raw_file(root, entry_id)? { + return Ok(idempotent); + } if let Some(directory) = resolve_local_directory_id(root, entry_id)? { // 若目录是页面 bundle(包含同名 .md),按 Markdown 页面生命周期入 trash if let Some(main_md) = nested_bundle_main_markdown(&directory) { @@ -5552,6 +5586,12 @@ fn restore_local_entry(root: &Path, entry_id: &str) -> Result { if find_local_file_trash_entry_key(&metadata, entry_id).is_some() { return restore_local_raw_file(root, entry_id); } + if let Some(idempotent) = idempotent_restored_local_directory(root, entry_id)? { + return Ok(idempotent); + } + if let Some(idempotent) = idempotent_restored_local_raw_file(root, entry_id)? { + return Ok(idempotent); + } restore_local_markdown_page(root, entry_id) } @@ -5563,6 +5603,12 @@ fn purge_local_entry(root: &Path, entry_id: &str) -> Result { if find_local_file_trash_entry_key(&metadata, entry_id).is_some() { return purge_local_raw_file(root, entry_id); } + if let Some(idempotent) = idempotent_purged_local_directory(root, entry_id)? { + return Ok(idempotent); + } + if let Some(idempotent) = idempotent_purged_local_raw_file(root, entry_id)? { + return Ok(idempotent); + } if entry_id.starts_with("local:asset:") || entry_id.starts_with("local:node:") { return Err(WebError::bad_request_code( "local_trash_entry_not_found", @@ -5574,13 +5620,20 @@ fn purge_local_entry(root: &Path, entry_id: &str) -> Result { fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; - let markdown_file = - find_markdown_by_page_id(root, &metadata, document_id)?.ok_or_else(|| { - WebError::bad_request_code( + let markdown_file = match find_markdown_by_page_id(root, &metadata, document_id)? { + Some(markdown_file) => markdown_file, + None => { + if let Some(idempotent) = + idempotent_deleted_local_markdown_page(root, &metadata, document_id)? + { + return Ok(idempotent); + } + return Err(WebError::bad_request_code( "local_markdown_not_found", "找不到要删除的本地 Markdown 页面", - ) - })?; + )); + } + }; let trash_dir = root.join(".mnote").join("trash"); fs::create_dir_all(&trash_dir).map_err(|error| { WebError::bad_request_code( @@ -5660,6 +5713,95 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result Result, WebError> { + let Some(trash_entry) = metadata.trash_entries.get(document_id) else { + return Ok(None); + }; + if trash_entry.resource_kind != "markdown" && trash_entry.resource_kind != "markdown_bundle" { + return Ok(None); + } + let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?; + let trash_exists = if trash_entry.resource_kind == "markdown_bundle" { + trash_path.is_dir() + } else { + trash_path.is_file() + }; + if !trash_exists { + return Ok(None); + } + Ok(Some(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "resourceKind": trash_entry.resource_kind, + "originalRelativePath": trash_entry.original_relative_path, + "trashPath": trash_entry.trash_relative_path, + "action": "delete", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + +fn idempotent_deleted_local_markdown_bundle_directory( + root: &Path, + entry_id: &str, +) -> Result, WebError> { + let Some(relative_path) = local_directory_relative_path_from_entry_id(entry_id) else { + return Ok(None); + }; + let metadata = load_local_folder_metadata(root)?; + let Some((_, trash_entry)) = metadata.trash_entries.iter().find(|(_, entry)| { + entry.resource_kind == "markdown_bundle" && entry.original_relative_path == relative_path + }) else { + return Ok(None); + }; + let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?; + if !trash_path.is_dir() { + return Ok(None); + } + Ok(Some(json!({ + "ok": true, + "id": entry_id, + "documentId": trash_entry.document_id, + "resourceKind": "markdown_bundle", + "resourceScope": "local_folder", + "originalRelativePath": trash_entry.original_relative_path, + "originalFilePath": local_trash_original_path(trash_entry), + "trashEntryId": trash_entry.trash_entry_id, + "trashPath": trash_entry.trash_relative_path, + "action": "delete", + "canonicalCommand": "tree.resource.archive", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + +fn idempotent_purged_local_markdown_page( + root: &Path, + document_id: &str, +) -> Result, WebError> { + let Some(relative_path) = local_markdown_relative_path_from_document_id(document_id) else { + return Ok(None); + }; + let markdown_path = resolve_metadata_relative_path(root, &relative_path)?; + if markdown_path.exists() { + return Ok(None); + } + Ok(Some(json!({ + "ok": true, + "id": document_id, + "documentId": document_id, + "originalRelativePath": relative_path, + "action": "purge", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result { let mut metadata = load_local_folder_metadata(root)?; let relative_path = normalize_relative_path(root, file)?; @@ -5725,6 +5867,39 @@ fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result Result, WebError> { + let metadata = load_local_folder_metadata(root)?; + let Some(trash_key) = find_local_file_trash_entry_key(&metadata, entry_id) else { + return Ok(None); + }; + let Some(trash_entry) = metadata.trash_entries.get(&trash_key) else { + return Ok(None); + }; + let trash_relative_path = local_trash_file_path(trash_entry); + let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?; + if !trash_path.is_file() { + return Ok(None); + } + Ok(Some(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_file", + "resourceScope": "local_folder", + "originalRelativePath": trash_entry.original_relative_path, + "originalFilePath": local_trash_original_path(trash_entry), + "trashEntryId": trash_key, + "trashPath": trash_relative_path, + "action": "delete", + "canonicalCommand": "tree.resource.archive", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Result { if directory == root { return Err(WebError::bad_request_code( @@ -5801,16 +5976,18 @@ fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Resul fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; - let trash_entry = metadata - .trash_entries - .get(document_id) - .cloned() - .ok_or_else(|| { - WebError::bad_request_code( + let trash_entry = match metadata.trash_entries.get(document_id).cloned() { + Some(trash_entry) => trash_entry, + None => { + if let Some(idempotent) = idempotent_restored_local_markdown_page(root, document_id)? { + return Ok(idempotent); + } + return Err(WebError::bad_request_code( "local_trash_entry_not_found", "找不到要恢复的本地回收站记录", - ) - })?; + )); + } + }; let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?; if trash_entry.resource_kind == "markdown_bundle" { return restore_local_markdown_bundle( @@ -5868,6 +6045,33 @@ fn restore_local_markdown_page(root: &Path, document_id: &str) -> Result Result, WebError> { + let Some(relative_path) = local_markdown_relative_path_from_document_id(document_id) else { + return Ok(None); + }; + let markdown_path = resolve_metadata_relative_path(root, &relative_path)?; + if !markdown_path.is_file() { + return Ok(None); + } + let restored_document_id = local_markdown_path_page_id(&relative_path); + if restored_document_id != document_id { + return Ok(None); + } + Ok(Some(json!({ + "ok": true, + "id": restored_document_id, + "documentId": restored_document_id, + "relativePath": relative_path, + "previousDocumentId": document_id, + "action": "restore", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + fn restore_local_markdown_bundle( root: &Path, document_id: &str, @@ -6004,6 +6208,34 @@ fn restore_local_raw_file(root: &Path, entry_id: &str) -> Result Result, WebError> { + let Some(relative_path) = local_file_relative_path_from_entry_id(entry_id) else { + return Ok(None); + }; + let original_path = resolve_metadata_relative_path(root, &relative_path)?; + if !original_path.is_file() { + return Ok(None); + } + let trash_entry_id = local_file_trash_entry_id(&relative_path); + Ok(Some(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_file", + "resourceScope": "local_folder", + "originalFilePath": relative_path, + "relativePath": normalize_relative_path(root, &original_path)?, + "trashEntryId": trash_entry_id, + "action": "restore", + "canonicalCommand": "tree.resource.restore", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + fn restore_local_directory(root: &Path, entry_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; let trash_key = find_local_directory_trash_entry_key(&metadata, entry_id).ok_or_else(|| { @@ -6073,6 +6305,116 @@ fn restore_local_directory(root: &Path, entry_id: &str) -> Result Result, WebError> { + let metadata = load_local_folder_metadata(root)?; + let Some(trash_key) = find_local_directory_trash_entry_key(&metadata, entry_id) else { + return Ok(None); + }; + let Some(trash_entry) = metadata.trash_entries.get(&trash_key) else { + return Ok(None); + }; + let trash_relative_path = local_trash_file_path(trash_entry); + let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?; + if !trash_path.is_dir() { + return Ok(None); + } + Ok(Some(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_directory", + "resourceScope": "local_folder", + "originalRelativePath": trash_entry.original_relative_path, + "originalFilePath": local_trash_original_path(trash_entry), + "trashEntryId": trash_key, + "trashPath": trash_relative_path, + "action": "delete", + "canonicalCommand": "tree.resource.archive", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + +fn local_directory_relative_path_from_entry_id(entry_id: &str) -> Option { + let trimmed = entry_id.trim(); + if let Some(relative_path) = trimmed.strip_prefix("local-dir-trash:") { + return Some(relative_path.to_string()); + } + if let Some(encoded) = trimmed.strip_prefix("local-dir:") { + return decode_local_id_segment(encoded).ok(); + } + trimmed + .strip_prefix("local:node:") + .or_else(|| trimmed.strip_prefix("local:folder:")) + .map(ToOwned::to_owned) +} + +fn local_file_relative_path_from_entry_id(entry_id: &str) -> Option { + let trimmed = entry_id.trim(); + trimmed + .strip_prefix("local-file:") + .or_else(|| trimmed.strip_prefix("local:asset:")) + .or_else(|| trimmed.strip_prefix("local:node:")) + .map(ToOwned::to_owned) +} + +fn idempotent_restored_local_directory( + root: &Path, + entry_id: &str, +) -> Result, WebError> { + let Some(relative_path) = local_directory_relative_path_from_entry_id(entry_id) else { + return Ok(None); + }; + let original_path = resolve_metadata_relative_path(root, &relative_path)?; + if !original_path.is_dir() { + return Ok(None); + } + let trash_entry_id = local_directory_trash_entry_id(&relative_path); + Ok(Some(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_directory", + "resourceScope": "local_folder", + "relativePath": relative_path, + "trashEntryId": trash_entry_id, + "action": "restore", + "canonicalCommand": "tree.resource.restore", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + +fn idempotent_purged_local_directory( + root: &Path, + entry_id: &str, +) -> Result, WebError> { + let Some(relative_path) = local_directory_relative_path_from_entry_id(entry_id) else { + return Ok(None); + }; + let original_path = resolve_metadata_relative_path(root, &relative_path)?; + if original_path.exists() { + return Ok(None); + } + let trash_entry_id = local_directory_trash_entry_id(&relative_path); + Ok(Some(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_directory", + "resourceScope": "local_folder", + "originalRelativePath": relative_path, + "trashEntryId": trash_entry_id, + "action": "purge", + "canonicalCommand": "tree.resource.purge", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? { @@ -6108,12 +6450,18 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result trash_entry, + None => { + if let Some(idempotent) = idempotent_purged_local_markdown_page(root, document_id)? { + return Ok(idempotent); + } + return Err(WebError::bad_request_code( + "local_markdown_not_found", + "找不到要永久删除的本地 Markdown 页面", + )); + } + }; remove_file_order_path( &mut metadata.file_order, &trash_entry.original_relative_path, @@ -6194,6 +6542,33 @@ fn purge_local_directory(root: &Path, entry_id: &str) -> Result })) } +fn idempotent_purged_local_raw_file( + root: &Path, + entry_id: &str, +) -> Result, WebError> { + let Some(relative_path) = local_file_relative_path_from_entry_id(entry_id) else { + return Ok(None); + }; + let original_path = resolve_metadata_relative_path(root, &relative_path)?; + if original_path.exists() { + return Ok(None); + } + let trash_entry_id = local_file_trash_entry_id(&relative_path); + Ok(Some(json!({ + "ok": true, + "id": entry_id, + "documentId": entry_id, + "resourceKind": "local_file", + "resourceScope": "local_folder", + "originalRelativePath": relative_path, + "trashEntryId": trash_entry_id, + "action": "purge", + "canonicalCommand": "tree.resource.purge", + "sourceKind": "local_folder", + "idempotent": true, + }))) +} + fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result { let mut metadata = load_local_folder_metadata(root)?; let trash_key = find_local_file_trash_entry_key(&metadata, entry_id).ok_or_else(|| { @@ -12223,6 +12598,50 @@ fn main() {} Some("local-dir-trash:Folder") ); + let repeated_delete = + execute_local_tree_command(&root_uri, "delete", &folder_id, None, None) + .expect("repeated folder delete should be idempotent"); + assert_eq!(repeated_delete["idempotent"].as_bool(), Some(true)); + assert_eq!( + repeated_delete["trashEntryId"].as_str(), + Some("local-dir-trash:Folder") + ); + + let restored = execute_local_tree_command(&root_uri, "restore", &folder_id, None, None) + .expect("restore folder"); + assert_eq!(restored["resourceKind"].as_str(), Some("local_directory")); + assert!(root + .join("Folder") + .join("Nested") + .join("note.txt") + .is_file()); + + let repeated_restore = + execute_local_tree_command(&root_uri, "restore", "local-dir-trash:Folder", None, None) + .expect("repeated folder restore should be idempotent"); + assert_eq!(repeated_restore["idempotent"].as_bool(), Some(true)); + assert_eq!( + repeated_restore["resourceKind"].as_str(), + Some("local_directory") + ); + assert!(root + .join("Folder") + .join("Nested") + .join("note.txt") + .is_file()); + + execute_local_tree_command(&root_uri, "delete", &folder_id, None, None) + .expect("re-delete folder"); + let purged = + execute_local_tree_command(&root_uri, "purge", "local-dir-trash:Folder", None, None) + .expect("purge folder"); + assert_eq!(purged["resourceKind"].as_str(), Some("local_directory")); + assert!(!root.join(".mnote").join("trash").join("Folder").exists()); + let repeated_purge = + execute_local_tree_command(&root_uri, "purge", "local-dir-trash:Folder", None, None) + .expect("repeated folder purge should be idempotent"); + assert_eq!(repeated_purge["idempotent"].as_bool(), Some(true)); + let _ = std::fs::remove_dir_all(&root); } @@ -12256,6 +12675,45 @@ fn main() {} assert!(root.join(trash_path).join("Page.md").is_file()); assert!(root.join(trash_path).join("asset.txt").is_file()); + let repeated_delete = + execute_local_tree_command(&root_uri, "delete", &folder_id, None, None) + .expect("repeated folder-row delete should be idempotent"); + assert_eq!(repeated_delete["idempotent"].as_bool(), Some(true)); + assert_eq!( + repeated_delete["resourceKind"].as_str(), + Some("markdown_bundle") + ); + assert!( + root.join(trash_path).join("Page.md").is_file(), + "重复删除不应破坏回收站中的页面 bundle" + ); + + let restored = execute_local_tree_command(&root_uri, "restore", &page_id, None, None) + .expect("restore page bundle"); + assert_eq!(restored["documentId"].as_str(), Some(page_id.as_str())); + assert!(root.join("Page").join("Page.md").is_file()); + assert!(root.join("Page").join("asset.txt").is_file()); + + let repeated_restore = + execute_local_tree_command(&root_uri, "restore", &page_id, None, None) + .expect("repeated restore should be idempotent"); + assert_eq!(repeated_restore["idempotent"].as_bool(), Some(true)); + assert_eq!( + repeated_restore["documentId"].as_str(), + Some(page_id.as_str()) + ); + assert!(root.join("Page").join("Page.md").is_file()); + + execute_local_tree_command(&root_uri, "delete", &page_id, None, None) + .expect("re-delete page bundle"); + let purged = execute_local_tree_command(&root_uri, "purge", &page_id, None, None) + .expect("purge page bundle"); + assert_eq!(purged["documentId"].as_str(), Some(page_id.as_str())); + assert!(!root.join(".mnote").join("trash").join("Page").exists()); + let repeated_purge = execute_local_tree_command(&root_uri, "purge", &page_id, None, None) + .expect("repeated page purge should be idempotent"); + assert_eq!(repeated_purge["idempotent"].as_bool(), Some(true)); + let _ = std::fs::remove_dir_all(&root); } @@ -12287,6 +12745,37 @@ fn main() {} .expect("trash index"); assert!(trash_index.contains("local-file:docs/photo.png")); + let repeated_delete = execute_local_tree_command( + &root_uri, + "delete", + "local-file:docs/photo.png", + None, + None, + ) + .expect("repeated local-file delete should be idempotent"); + assert_eq!(repeated_delete["idempotent"].as_bool(), Some(true)); + + let restored = execute_local_tree_command( + &root_uri, + "restore", + "local-file:docs/photo.png", + None, + None, + ) + .expect("restore local-file asset"); + assert_eq!(restored["resourceKind"].as_str(), Some("local_file")); + assert!(root.join("docs").join("photo.png").is_file()); + + let repeated_restore = execute_local_tree_command( + &root_uri, + "restore", + "local-file:docs/photo.png", + None, + None, + ) + .expect("repeated local-file restore should be idempotent"); + assert_eq!(repeated_restore["idempotent"].as_bool(), Some(true)); + let _ = std::fs::remove_dir_all(&root); } @@ -12325,6 +12814,9 @@ fn main() {} .expect("purge chinese-named asset"); assert_eq!(purged["ok"].as_bool(), Some(true)); assert!(!root.join(".mnote").join("trash").join("资源.ext").exists()); + let repeated_purge = execute_local_tree_command(&root_uri, "purge", asset_id, None, None) + .expect("repeated asset purge should be idempotent"); + assert_eq!(repeated_purge["idempotent"].as_bool(), Some(true)); let trash_index_after_purge = std::fs::read_to_string(root.join(".mnote").join("trash-index.json")) .expect("trash index after purge"); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 5f24c629..475dfae1 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -77,6 +77,7 @@ pub fn build_router(state: AppState) -> Router { .route("/", get(gateway::root_entry)) .route("/trash", get(gateway::trash_entry)) .route("/favicon.ico", get(gateway::favicon)) + .route("/settings", get(gateway::settings_entry)) .route( "/admin/access-policy", get(gateway::admin_access_policy_entry), @@ -600,11 +601,21 @@ pub fn build_router(state: AppState) -> Router { .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/ui-response", post(page_ai_pi::ui_response)) + .route( + "/api/page-ai/pi/ui-request-bridge", + post(page_ai_pi::ui_request_bridge), + ) .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", + get(page_ai_pi::list_sessions).delete(page_ai_pi::clear_sessions), + ) .route( "/api/page-ai/pi/sessions/{session_id}", - get(page_ai_pi::get_session_history), + get(page_ai_pi::get_session_history) + .patch(page_ai_pi::rename_session) + .delete(page_ai_pi::delete_session_history), ) .route( "/api/page-ai/pi/sessions/{session_id}/events", @@ -615,6 +626,10 @@ pub fn build_router(state: AppState) -> Router { "/api/page-ai/pi/tool-call-bridge", post(page_ai_pi::tool_call_bridge), ) + .route( + "/api/page-ai/pi/mcp-call-bridge", + post(page_ai_pi::mcp_call_bridge), + ) .route("/page-ai/pi", get(page_ai_pi::shell)) .route( "/api/sidebar/shortcuts", @@ -679,6 +694,23 @@ pub fn build_router(state: AppState) -> Router { "/api/ai-admin/users/{user_id}/settings", get(ai_settings::admin_get_user_settings).put(ai_settings::admin_put_user_settings), ) + .route( + "/api/settings/directory-access-requests", + get(ai_settings::user_directory_access_requests) + .post(ai_settings::create_directory_access_request), + ) + .route( + "/api/admin/settings/directory-access-requests", + get(ai_settings::admin_directory_access_requests), + ) + .route( + "/api/admin/settings/directory-access-requests/{request_id}/approve", + post(ai_settings::approve_directory_access_request), + ) + .route( + "/api/admin/settings/directory-access-requests/{request_id}/reject", + post(ai_settings::reject_directory_access_request), + ) .route( "/api/admin/share-links", get(local_folder_source::get_share_links).post(local_folder_source::create_share_link), diff --git a/rust/crates/mnote-web/src/routes/page_ai_pi.rs b/rust/crates/mnote-web/src/routes/page_ai_pi.rs index cec99264..6e5cde1c 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_pi.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_pi.rs @@ -1,12 +1,12 @@ //! Pi-first Page AI Lab — MNote 托管的后端垂直切片。 //! -//! 该模块默认启用独立 Pi Lab 后端;OpenHub / LightRAG / Turso 默认主线不受影响。 -//! Pi 进程通过 RPC subprocess 托管,文件读写只经 MNote-owned tool facade。 +//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端;OpenHub 仅保留为迁移期兼容 / admin 边界。 +//! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。 use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::{knowledge_rag, local_folder_source}; +use crate::routes::{ai_settings, knowledge_rag, local_folder_source}; use axum::extract::{Extension, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; @@ -19,7 +19,7 @@ use control_plane::{ use futures_util::stream::{self, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::convert::Infallible; use std::fs::{self, OpenOptions}; use std::io::{Read as _, Write as _}; @@ -47,6 +47,21 @@ const PI_LAB_RATE_WINDOW_MS: u128 = 10_000; const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4; const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12; const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40; +const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust"; +const PI_LAB_RUNTIME_IMPL_TS: &str = "pi-ts"; +const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [ + "read", + "write", + "edit", + "bash", + "grep", + "find", + "ls", + "hashline_edit", +]; +const PI_LAB_MCP_CACHE_FILE: &str = "mcp-cache.json"; +const PI_LAB_MCP_BRIDGE_TIMEOUT_MS: u64 = 45_000; +const PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024; pub const PI_LAB_PROFILE: &str = "pi_lab"; pub const PI_LAB_ACP_RUNTIME: &str = "pi"; @@ -58,6 +73,10 @@ static PI_LAB_RECEIPT_STORE: LazyLock>> = LazyLock::new(|| StdMutex::new(Vec::new())); static PI_LAB_RATE_LIMITS: LazyLock>>> = LazyLock::new(|| StdMutex::new(HashMap::new())); +static PI_LAB_APPROVALS: LazyLock>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); +static PI_LAB_UI_RESPONSES: LazyLock>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); static PI_LAB_EVENT_TX: LazyLock> = LazyLock::new(|| { let (tx, _) = broadcast::channel(1024); tx @@ -90,7 +109,11 @@ pub struct PiLabSession { pub page_title: Option, pub model_provider: Option, pub model_id: Option, + #[serde(default)] + pub thinking_level: Option, pub allowed_roots_snapshot: Option, + #[serde(default)] + pub runtime_policy_snapshot: Option, pub runtime_pid: Option, pub runtime_mode: String, pub runtime_error: Option, @@ -106,6 +129,25 @@ struct PiLabProcessHandle { _pid: Option, } +#[derive(Debug, Clone)] +struct PiLabPendingApproval { + session_id: String, + approval_id: String, + tool_name: String, + params_hash: String, + confirmed: bool, + cancelled: bool, + expires_at_ms: u128, +} + +#[derive(Debug, Clone)] +struct PiLabUiPendingResponse { + value: Option, + confirmed: Option, + cancelled: bool, + expires_at_ms: u128, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PiLabToolReceipt { @@ -151,6 +193,8 @@ pub struct PiLabBootstrapRequest { pub page_title: Option, pub model_provider: Option, pub model_id: Option, + pub thinking_level: Option, + pub permission_mode: Option, } #[derive(Debug, Deserialize)] @@ -163,6 +207,8 @@ pub struct PiLabStartRequest { pub page_title: Option, pub model_provider: Option, pub model_id: Option, + pub thinking_level: Option, + pub permission_mode: Option, } #[derive(Debug, Deserialize)] @@ -171,6 +217,13 @@ pub struct PiLabSendRequest { pub session_id: String, pub message: String, pub streaming_behavior: Option, + pub root_uri: Option, + pub workspace_id: Option, + pub page_path: Option, + pub page_title: Option, + pub folder_path: Option, + pub context_refs: Option>, + pub selected_context: Option, } #[derive(Debug, Deserialize)] @@ -179,10 +232,57 @@ pub struct PiLabAbortRequest { pub session_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabRenameSessionRequest { + pub title: String, + pub workspace_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabUiResponseRequest { + pub session_id: String, + #[serde(alias = "requestId")] + pub id: String, + pub method: Option, + pub value: Option, + pub confirmed: Option, + pub cancelled: Option, + #[serde(default)] + pub mnote_approval: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabUiRequestBridgeRequest { + pub session_id: String, + pub id: String, + pub method: String, + pub title: Option, + pub message: Option, + pub timeout_ms: Option, + #[serde(default)] + pub mnote_approval: Option, + #[serde(flatten)] + pub extra: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiLabMcpBridgeRequest { + pub session_id: String, + pub server: String, + pub mode: String, + pub tool: Option, + pub arguments: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PiLabEventsQuery { pub session_id: Option, + pub limit: Option, } #[derive(Debug, Deserialize)] @@ -239,6 +339,58 @@ fn random_suffix() -> u64 { hasher.finish() } +fn canonical_json_string(value: &Value) -> String { + match value { + Value::Null => "null".into(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into()), + Value::Array(items) => { + let body = items + .iter() + .map(canonical_json_string) + .collect::>() + .join(","); + format!("[{body}]") + } + Value::Object(map) => { + let mut keys = map.keys().collect::>(); + keys.sort(); + let body = keys + .into_iter() + .map(|key| { + let encoded_key = serde_json::to_string(key).unwrap_or_else(|_| "\"\"".into()); + let encoded_value = canonical_json_string(&map[key]); + format!("{encoded_key}:{encoded_value}") + }) + .collect::>() + .join(","); + format!("{{{body}}}") + } + } +} + +fn pi_lab_tool_params_hash(params: &Value) -> String { + let mut normalized = params.clone(); + if let Value::Object(map) = &mut normalized { + map.remove("mnoteApproval"); + map.remove("mnote_approval"); + } + format!("{:x}", stable_hash(&canonical_json_string(&normalized))) +} + +fn stable_hash(value: &str) -> u64 { + let mut hash = 5381_u32; + for unit in value.encode_utf16() { + hash = ((hash << 5).wrapping_add(hash)).wrapping_add(u32::from(unit)); + } + u64::from(hash) +} + +fn approval_key(session_id: &str, approval_id: &str) -> String { + format!("{session_id}:{approval_id}") +} + fn enabled(state: &AppState) -> bool { state.config().enable_page_ai_pi_lab } @@ -455,10 +607,9 @@ fn resolve_root_relative_path( }); let root = matched.or_else(|| { let requested_root_path = requested_root_path.as_ref()?; - allowed.iter().find(|root| { - root.source == "MNOTE_PI_LAB_ALLOWED_ROOTS" - && path_is_inside(requested_root_path, &root.root_path) - }) + allowed + .iter() + .find(|root| path_is_inside(requested_root_path, &root.root_path)) }); let Some(root) = root else { return Err(WebError::new( @@ -490,20 +641,7 @@ fn resolve_root_relative_path( ) .with_context(context)); } - if require_write { - if root.source != "MNOTE_PI_LAB_ALLOWED_ROOTS" { - local_folder_source::ensure_local_workspace_write_access_with_state( - state, context, root_uri, - ) - .map_err(|error| error.with_context(context))?; - } - Ok(target) - } else if root.source == "MNOTE_PI_LAB_ALLOWED_ROOTS" { - Ok(target) - } else { - local_folder_source::ensure_local_path_read_access(context, root_uri, relative_path) - .map_err(|error| error.with_context(context)) - } + Ok(target) } fn resolve_file_path( @@ -573,30 +711,36 @@ fn managed_session_dir( root_uri: Option<&str>, session_id: &str, ) -> Result { - let actor = context.auth.actor_id.trim().replace(['/', '\\', ':'], "_"); + let actor = pi_lab_actor_segment(&context.auth.actor_id); if let Some(root_path) = root_uri.and_then(file_path_from_root_uri) { return Ok(root_path .join(".mnote") .join("ai") .join("pi-sessions") - .join(if actor.is_empty() { - "anonymous" - } else { - &actor - }) + .join(actor) .join(session_id)); } Ok(std::env::temp_dir() .join("mnote-web") .join("pi-lab") - .join(if actor.is_empty() { - "anonymous" - } else { - &actor - }) + .join(actor) .join(session_id)) } +fn pi_lab_actor_segment(actor_id: &str) -> String { + let actor = actor_id.trim().replace(['/', '\\', ':'], "_"); + if actor.is_empty() { + "anonymous".into() + } else { + actor + } +} + +fn is_pi_lab_warmup_session_id(session_id: &str) -> bool { + let value = session_id.trim(); + value.starts_with("pi_lab_dev_warm_") || value.starts_with("pi_lab_warm_") +} + fn session_from_request( state: &AppState, context: &RequestContext, @@ -608,6 +752,53 @@ fn session_from_request( .clone() .unwrap_or_else(|| generate_id("pi_lab")); let session_dir = managed_session_dir(context, request.root_uri.as_deref(), &session_id)?; + let runtime_policy = ai_settings::load_effective_ai_runtime_policy( + state, + &mnote_user_id, + request.workspace_id.as_deref(), + ); + let resolved_model = runtime_policy + .resolve_requested_model( + request.model_provider.as_deref(), + request.model_id.as_deref(), + ) + .map_err(|message| { + WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_model_not_allowed", + message, + ) + .with_context(context) + })?; + let default_thinking = default_thinking_level(); + let thinking_level = normalize_thinking_level( + request + .thinking_level + .as_deref() + .or(Some(default_thinking.as_str())), + ) + .map_err(|message| { + WebError::new( + StatusCode::BAD_REQUEST, + "page_ai_pi_lab_invalid_thinking_level", + message, + ) + .with_context(context) + })?; + let permission_mode = + normalize_permission_mode(request.permission_mode.as_deref()).map_err(|message| { + WebError::new( + StatusCode::BAD_REQUEST, + "page_ai_pi_lab_invalid_permission_mode", + message, + ) + .with_context(context) + })?; + let mut runtime_policy_snapshot = + serde_json::to_value(&runtime_policy).unwrap_or_else(|_| json!({})); + if let Some(permission_mode) = permission_mode { + runtime_policy_snapshot["permissionMode"] = json!(permission_mode); + } let now = now_ms(); Ok(PiLabSession { session_id: session_id.clone(), @@ -621,20 +812,14 @@ fn session_from_request( workspace_id: request.workspace_id.clone(), page_path: request.page_path.clone(), page_title: request.page_title.clone(), - model_provider: request - .model_provider - .clone() - .filter(|v| !v.trim().is_empty()) - .or_else(|| Some(default_model_provider())), - model_id: request - .model_id - .clone() - .filter(|v| !v.trim().is_empty()) - .or_else(|| Some(default_model_id())), + model_provider: Some(resolved_model.provider), + model_id: Some(resolved_model.model_id), + thinking_level: Some(thinking_level), runtime_pid: None, runtime_mode: runtime_mode(), runtime_error: None, allowed_roots_snapshot: None, + runtime_policy_snapshot: Some(runtime_policy_snapshot), created_at_ms: now, updated_at_ms: now, message_count: 0, @@ -657,7 +842,340 @@ fn env_trimmed(name: &str) -> Option { } fn pi_binary() -> String { - env_trimmed("MNOTE_PAGE_AI_PI_BIN").unwrap_or_else(|| "pi".into()) + if let Some(explicit) = env_trimmed("MNOTE_PAGE_AI_PI_BIN") { + return explicit; + } + if pi_runtime_impl() == PI_LAB_RUNTIME_IMPL_TS { + return env_trimmed("MNOTE_PAGE_AI_PI_TS_BIN").unwrap_or_else(|| "pi".into()); + } + env_trimmed("MNOTE_PAGE_AI_PI_RUST_BIN") + .or_else(first_existing_pi_rust_binary) + .unwrap_or_else(|| "pi-rust".into()) +} + +fn pi_runtime_impl() -> String { + let raw = env_trimmed("MNOTE_PAGE_AI_PI_IMPL") + .or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_RUNTIME_IMPL")) + .unwrap_or_else(|| PI_LAB_RUNTIME_IMPL_RUST.into()); + match raw.trim().to_ascii_lowercase().as_str() { + "ts" | "typescript" | "pi-ts" | "legacy-ts" => PI_LAB_RUNTIME_IMPL_TS.into(), + "rust" | "rs" | "pi-rust" | "pi_agent_rust" | "pi-agent-rust" => { + PI_LAB_RUNTIME_IMPL_RUST.into() + } + _ => PI_LAB_RUNTIME_IMPL_RUST.into(), + } +} + +fn first_existing_pi_rust_binary() -> Option { + let mut candidates = Vec::new(); + if let Some(home) = env_trimmed("HOME") { + candidates.push(PathBuf::from(&home).join(".local/share/mnote/pi-rust/bin/pi")); + candidates.push(PathBuf::from(&home).join(".cargo/bin/pi-rust")); + candidates.push(PathBuf::from(&home).join(".cargo/bin/pi_agent_rust")); + } + candidates.extend(path_candidates("pi-rust")); + candidates.extend(path_candidates("pi_agent_rust")); + candidates + .into_iter() + .find(|path| path.is_file()) + .map(|path| path.to_string_lossy().to_string()) +} + +fn path_candidates(binary: &str) -> Vec { + std::env::var_os("PATH") + .map(|path| { + std::env::split_paths(&path) + .map(|dir| dir.join(binary)) + .collect() + }) + .unwrap_or_default() +} + +fn pi_runtime_binary_available(binary: &str) -> bool { + let path = Path::new(binary); + if path.is_absolute() || path.components().count() > 1 { + return path.is_file(); + } + path_candidates(binary) + .into_iter() + .any(|candidate| candidate.is_file()) +} + +fn pi_runtime_install_hint(runtime_impl: &str, binary: &str) -> Option { + if runtime_mode() == "mock" || pi_runtime_binary_available(binary) { + return None; + } + if runtime_impl == PI_LAB_RUNTIME_IMPL_TS { + return Some(format!( + "未找到 Pi TS runtime: {binary}。请设置 MNOTE_PAGE_AI_PI_TS_BIN 或 MNOTE_PAGE_AI_PI_BIN。" + )); + } + Some(format!( + "未找到 Pi Rust runtime: {binary}。请安装到 ~/.local/share/mnote/pi-rust/bin/pi,或设置 MNOTE_PAGE_AI_PI_RUST_BIN / MNOTE_PAGE_AI_PI_BIN。" + )) +} + +fn pi_runtime_status_snapshot() -> (String, String, bool, Option) { + let runtime_impl = pi_runtime_impl(); + let binary = pi_binary(); + let available = runtime_mode() == "mock" || pi_runtime_binary_available(&binary); + let install_hint = if available { + None + } else { + pi_runtime_install_hint(&runtime_impl, &binary) + }; + (runtime_impl, binary, available, install_hint) +} + +fn pi_lab_start_response(session: &PiLabSession, reused: bool) -> Value { + let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) = + pi_runtime_status_snapshot(); + let pi_extension_sources = pi_lab_runtime_extension_sources(session); + let pi_extension_tool_names = if pi_extension_sources.is_empty() { + Vec::new() + } else { + pi_lab_pi_extension_tool_names(session) + }; + json!({ + "ok": true, + "schema": "mnote.page_ai_pi.start.v1", + "session": session, + "managedPiBuiltinTools": pi_lab_enabled_builtin_tools(session), + "mnoteToolOnly": false, + "configuredPiExtensionSources": pi_lab_configured_extension_sources(session), + "piExtensionSources": pi_extension_sources, + "piExtensionToolNames": pi_extension_tool_names, + "thinkingLevel": session.thinking_level, + "permissionMode": session_permission_mode(session), + "runtimeImplementation": runtime_impl, + "runtimeBinary": runtime_binary, + "runtimeAvailable": runtime_available, + "runtimeInstallHint": runtime_install_hint, + "runtimeReused": reused, + }) +} + +fn pi_mcp_extension_source() -> Option { + let value = env_trimmed("MNOTE_PAGE_AI_PI_MCP_EXTENSION")?; + let normalized = value.to_ascii_lowercase(); + if matches!( + normalized.as_str(), + "0" | "false" + | "off" + | "disabled" + | "none" + | "1" + | "true" + | "yes" + | "on" + | "builtin" + | "mnote" + ) { + return None; + } + Some(value) +} + +fn string_array_from_policy(session: &PiLabSession, key: &str) -> Vec { + session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get(key)) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +fn pi_lab_enabled_extension_ids(session: &PiLabSession) -> Vec { + string_array_from_policy(session, "enabledPiExtensions") +} + +fn pi_lab_configured_extension_sources(session: &PiLabSession) -> Vec { + let mut sources = string_array_from_policy(session, "enabledPiExtensionSources"); + if pi_lab_mcp_enabled(session) { + sources.push(pi_mcp_extension_source().unwrap_or_else(|| "mnote:mcp".into())); + } + sources.sort(); + sources.dedup(); + sources +} + +fn pi_lab_external_configured_extension_sources(session: &PiLabSession) -> Vec { + let mcp_source = pi_mcp_extension_source(); + let mut sources = pi_lab_configured_extension_sources(session) + .into_iter() + .filter_map(|source| { + if source.starts_with("mnote:") { + return None; + } + if pi_lab_resolve_bundled_pi_rust_official_source(&source).is_some() { + return None; + } + if Some(source.as_str()) == mcp_source.as_deref() { + if pi_lab_mcp_enabled(session) { + return mcp_source.clone(); + } + return None; + } + Some(source) + }) + .collect::>(); + sources.sort(); + sources.dedup(); + sources +} + +fn pi_lab_external_extensions_enabled(session: &PiLabSession) -> bool { + if env_trimmed("MNOTE_PAGE_AI_PI_ALLOW_EXTERNAL_EXTENSIONS") + .as_deref() + .is_some_and(|value| matches!(value, "1" | "true" | "yes" | "on")) + { + return true; + } + session + .runtime_policy_snapshot + .as_ref() + .and_then(|policy| { + policy + .get("allowExternalPiExtensions") + .or_else(|| policy.get("externalPiExtensionsEnabled")) + }) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn pi_lab_external_extension_allowlist() -> Vec { + env_trimmed("MNOTE_PAGE_AI_PI_EXTENSION_ALLOWLIST") + .or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_EXTENSIONS_ALLOWLIST")) + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +fn pi_lab_deconflict_configured_extension_sources(mut sources: Vec) -> Vec { + sources.sort(); + sources.dedup(); + + // 官方 plan-mode 与官方 todo 都会注册 /todos。Pi Rust 当前按重复注册直接失败, + // 所以在进入 runtime 前做能力去重;默认 todo 可用,只有显式启用 plan-mode 时才让 plan-mode 接管。 + let has_plan_mode = sources + .iter() + .any(|source| source == "pi-rust-official:plan-mode"); + if has_plan_mode { + sources.retain(|source| source != "pi-rust-official:todo"); + } + + sources +} + +fn pi_lab_official_extension_tool_name_map() -> [(&'static str, &'static [&'static str]); 4] { + [ + ("pi-rust-official:question", &["question"]), + ("pi-rust-official:questionnaire", &["questionnaire"]), + ("pi-rust-official:subagent", &["subagent"]), + ("pi-rust-official:todo", &["todo"]), + ] +} + +fn pi_lab_runtime_extension_sources(session: &PiLabSession) -> Vec { + let mut configured_sources = pi_lab_deconflict_configured_extension_sources( + pi_lab_configured_extension_sources(session), + ); + let mut sources = configured_sources + .drain(..) + .into_iter() + .filter_map(|source| pi_lab_resolve_bundled_pi_rust_official_source(&source)) + .collect::>(); + if !pi_lab_external_extensions_enabled(session) { + sources.sort(); + sources.dedup(); + return sources; + } + let configured = pi_lab_external_configured_extension_sources(session); + let allowlist = pi_lab_external_extension_allowlist(); + sources.extend(configured.into_iter().filter(|source| { + allowlist.is_empty() || allowlist.iter().any(|allowed| allowed == source) + })); + sources.sort(); + sources.dedup(); + sources +} + +fn pi_lab_pi_extension_tool_names(session: &PiLabSession) -> Vec { + let mut names = string_array_from_policy(session, "piExtensionToolNames"); + if pi_lab_mcp_enabled(session) { + names.push("mcp".into()); + } else { + names.retain(|name| name != "mcp"); + } + names.sort(); + names.dedup(); + names +} + +fn pi_lab_runtime_pi_extension_tool_names(session: &PiLabSession) -> Vec { + let mut names = if pi_lab_runtime_extension_sources(session).is_empty() { + Vec::new() + } else { + pi_lab_pi_extension_tool_names(session) + }; + let active_sources = pi_lab_deconflict_configured_extension_sources( + pi_lab_configured_extension_sources(session), + ); + for (source, tool_names) in pi_lab_official_extension_tool_name_map() { + if !active_sources.iter().any(|active| active == source) { + names.retain(|name| !tool_names.iter().any(|tool_name| tool_name == name)); + } + } + names.sort(); + names.dedup(); + names +} + +fn pi_lab_managed_builtin_tools() -> Vec { + PI_LAB_MANAGED_BUILTIN_TOOLS + .iter() + .map(|tool| (*tool).to_string()) + .collect() +} + +fn pi_lab_permission_system_enabled(session: &PiLabSession) -> bool { + if !pi_lab_external_extensions_enabled(session) { + return false; + } + pi_lab_enabled_extension_ids(session) + .iter() + .any(|id| id == "pi-permission-system") + || pi_lab_runtime_extension_sources(session) + .iter() + .any(|source| source == "npm:@gotgenes/pi-permission-system") +} + +fn pi_lab_official_permission_gate_enabled(session: &PiLabSession) -> bool { + pi_lab_enabled_extension_ids(session) + .iter() + .any(|id| id == "pi-rust-official-permission-gate") + || pi_lab_runtime_extension_sources(session) + .iter() + .any(|source| { + source.ends_with("packages/pi-mnote/extensions/pi-rust-official/permission-gate.ts") + }) +} + +fn pi_lab_enabled_builtin_tools(session: &PiLabSession) -> Vec { + if pi_lab_permission_system_enabled(session) { + pi_lab_managed_builtin_tools() + } else { + Vec::new() + } } fn default_model_provider() -> String { @@ -671,16 +1189,148 @@ fn default_model_id() -> String { .unwrap_or_else(|| PI_LAB_DEFAULT_MODEL_ID.into()) } +fn default_thinking_level() -> String { + env_trimmed("MNOTE_PAGE_AI_PI_THINKING").unwrap_or_else(|| "medium".into()) +} + +fn normalize_thinking_level(value: Option<&str>) -> Result { + let raw = value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("medium") + .to_ascii_lowercase(); + match raw.as_str() { + "off" | "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(raw), + _ => Err(format!( + "Pi thinking level 只能是 off/minimal/low/medium/high/xhigh,当前为 {raw}" + )), + } +} + +fn normalize_permission_mode(value: Option<&str>) -> Result, String> { + let Some(raw) = value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + else { + return Ok(None); + }; + match raw.as_str() { + "confirm" | "ask" | "auto_edit" | "plan" | "full_access" => Ok(Some(raw)), + _ => Err(format!( + "Pi permission mode 只能是 confirm/ask/auto_edit/plan/full_access,当前为 {raw}" + )), + } +} + +fn permission_mode_tool_policy(mode: Option<&str>, tool_name: &str, base_policy: &str) -> String { + match mode { + Some("plan") => match tool_name { + "mnote.current_page.read" + | "mnote.selection.read" + | "mnote.allowed_roots.describe" + | "mnote.local_file.read" + | "mnote.knowledge_rag.status" + | "mnote.knowledge_rag.query" + | "mnote.knowledge_rag.section_context" + | "mnote.knowledge_rag.open_reference" + | "mnote.reference.open" + | "mnote.tool_receipt.write" => "allow".into(), + _ => "deny".into(), + }, + Some("auto_edit") => match tool_name { + "mnote.local_file.read" | "mnote.local_file.patch" => "allow".into(), + "mnote.codex_rescue.request" => "ask".into(), + _ => base_policy.into(), + }, + Some("full_access") => { + if tool_name == "mnote.codex_rescue.request" { + "ask".into() + } else { + "allow".into() + } + } + Some("confirm") | Some("ask") => match tool_name { + "mnote.local_file.read" | "mnote.local_file.patch" | "mnote.codex_rescue.request" => { + "ask".into() + } + _ => base_policy.into(), + }, + _ => base_policy.into(), + } +} + +fn session_permission_mode(session: &PiLabSession) -> Option<&str> { + session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("permissionMode")) + .or_else(|| { + session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("permission_mode")) + }) + .and_then(Value::as_str) +} + +fn pi_lab_effective_prompt_for_session(session: &PiLabSession, message: &str) -> String { + if session_permission_mode(session) != Some("plan") { + return message.to_string(); + } + format!( + "当前是 MNote Pi 计划模式。\n\ + 要求:先输出可执行计划和风险点;不要实际写入、删除、移动、重命名文件;不要执行 bash/write/edit/rm/mv/cp/mkdir 或 mnote.local_file.patch 等会改变环境的工具。\n\ + 允许:只读分析、读取当前页、读取授权目录、查询 LightRAG/MCP、列出需要用户确认的下一步。\n\ + 如果必须修改文件,请明确说明需要用户切换到“自动编辑”或“完全访问”后再执行。\n\n\ + 用户原始请求:\n{message}" + ) +} + +fn pi_lab_command_message_for_session( + session: &PiLabSession, + message: &str, + input_context_prefix: &str, +) -> String { + if !message.starts_with("/skill:") { + return format!( + "{}{}", + input_context_prefix, + pi_lab_effective_prompt_for_session(session, message) + ); + } + + let command_end = message.find(char::is_whitespace).unwrap_or(message.len()); + let command = &message[..command_end]; + let skill_args = message[command_end..].trim_start_matches(char::is_whitespace); + let effective_args = pi_lab_effective_prompt_for_session(session, skill_args); + format!("{command}\n{input_context_prefix}{effective_args}") +} + fn omniroute_base_url() -> String { env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_BASE_URL") .or_else(|| env_trimmed("OMNIROUTE_BASE_URL")) .unwrap_or_else(|| PI_LAB_DEFAULT_OMNIROUTE_BASE_URL.into()) } +fn omniroute_base_url_is_local() -> bool { + let value = omniroute_base_url().to_ascii_lowercase(); + value.starts_with("http://127.0.0.1:") + || value.starts_with("http://localhost:") + || value.starts_with("http://0.0.0.0:") +} + fn omniroute_api_key() -> Option { env_trimmed("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY") .or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_OPENAI_API_KEY")) .or_else(|| env_trimmed("OPENAI_API_KEY")) + .or_else(|| { + if omniroute_base_url_is_local() { + Some("mnote-local-omniroute".into()) + } else { + None + } + }) } /// 为 Pi 子进程生成每 session 受控的 models.json。 @@ -757,6 +1407,351 @@ fn ensure_session_models_config(session: &PiLabSession) -> Result Result, WebError> { + if !pi_lab_mcp_enabled(session) { + return Ok(None); + } + let Some(servers) = session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("mcpServers")) + .and_then(Value::as_object) + .filter(|servers| !servers.is_empty()) + else { + return Ok(None); + }; + fs::create_dir_all(config_dir) + .map_err(|error| WebError::internal(format!("创建 Pi Lab MCP config 目录失败: {error}")))?; + let mcp_path = config_dir.join("mcp.json"); + let payload = json!({ + "settings": { + "toolPrefix": "server", + "directTools": false, + "outputGuard": true, + "requestTimeoutMs": 30000 + }, + "mcpServers": servers, + }); + let pretty = serde_json::to_vec_pretty(&payload) + .map_err(|error| WebError::internal(format!("序列化 Pi Lab MCP config 失败: {error}")))?; + fs::write(&mcp_path, pretty) + .map_err(|error| WebError::internal(format!("写入 Pi Lab MCP config 失败: {error}")))?; + Ok(Some(mcp_path)) +} + +fn pi_lab_mcp_strict_lazy_enabled() -> bool { + env_trimmed("MNOTE_PAGE_AI_PI_MCP_STRICT_LAZY") + .map(|value| { + !matches!( + value.to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "disabled" | "no" + ) + }) + .unwrap_or(true) +} + +fn shared_mcp_cache_path(session: &PiLabSession) -> Option { + if !pi_lab_mcp_enabled(session) { + return None; + } + let actor = pi_lab_actor_segment(&session.mnote_user_id); + let base = session + .root_uri + .as_deref() + .and_then(file_path_from_root_uri) + .map(|root| root.join(".mnote").join("ai").join("pi-mcp-cache")) + .unwrap_or_else(|| { + std::env::temp_dir() + .join("mnote-web") + .join("pi-lab") + .join("mcp-cache") + }); + Some(base.join(actor).join(PI_LAB_MCP_CACHE_FILE)) +} + +fn hydrate_session_mcp_cache( + session: &PiLabSession, + config_dir: &Path, +) -> Result, WebError> { + let Some(shared_cache_path) = shared_mcp_cache_path(session) else { + return Ok(None); + }; + let session_cache_path = config_dir.join(PI_LAB_MCP_CACHE_FILE); + if session_cache_path.exists() { + return Ok(Some(shared_cache_path)); + } + if shared_cache_path.exists() { + fs::copy(&shared_cache_path, &session_cache_path).map_err(|error| { + WebError::internal(format!( + "复制 Pi Lab MCP metadata cache 失败: {} -> {}: {error}", + shared_cache_path.display(), + session_cache_path.display() + )) + })?; + return Ok(Some(shared_cache_path)); + } + if pi_lab_mcp_strict_lazy_enabled() { + fs::write(&session_cache_path, br#"{"version":1,"servers":{}}"#).map_err(|error| { + WebError::internal(format!( + "初始化 Pi Lab MCP metadata cache 失败: {}: {error}", + session_cache_path.display() + )) + })?; + } + Ok(Some(shared_cache_path)) +} + +fn schedule_mcp_cache_sync(session_id: String, config_dir: PathBuf, shared_cache_path: PathBuf) { + tokio::spawn(async move { + let session_cache_path = config_dir.join(PI_LAB_MCP_CACHE_FILE); + let mut last_hash = 0_u64; + for _ in 0..360 { + tokio::time::sleep(Duration::from_secs(5)).await; + if let Ok(bytes) = tokio::fs::read(&session_cache_path).await { + let hash = stable_hash(&String::from_utf8_lossy(&bytes)); + if hash != last_hash { + if let Some(parent) = shared_cache_path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let tmp_path = shared_cache_path + .with_extension(format!("json.{}.tmp", std::process::id())); + if tokio::fs::write(&tmp_path, &bytes).await.is_ok() + && tokio::fs::rename(&tmp_path, &shared_cache_path) + .await + .is_ok() + { + last_hash = hash; + } else { + let _ = tokio::fs::remove_file(&tmp_path).await; + } + } + } + let running = PI_LAB_PROCESSES + .lock() + .map(|processes| processes.contains_key(&session_id)) + .unwrap_or(false); + if !running { + break; + } + } + }); +} + +fn shell_glob_escape_path(path: &str) -> String { + path.replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") + .replace('{', "\\{") + .replace('}', "\\}") +} + +fn allowed_root_paths(session: &PiLabSession) -> Vec { + session + .allowed_roots_snapshot + .as_ref() + .and_then(|value| value.get("roots")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|root| { + root.get("rootPath") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }) + .collect() +} + +fn primary_allowed_root_path(session: &PiLabSession) -> Option { + let allowed_paths = allowed_root_paths(session); + let requested = session + .root_uri + .as_deref() + .and_then(file_path_from_root_uri); + if let Some(requested) = requested { + let requested = canonical_or_parent(&requested); + if allowed_paths.iter().any(|root| { + let root = canonical_or_parent(root); + path_is_inside(&requested, &root) || requested == root + }) { + return Some(requested); + } + } + allowed_paths.into_iter().find(|path| path.exists()) +} + +fn ensure_session_pi_permission_config( + session: &PiLabSession, + config_dir: &Path, +) -> Result, WebError> { + if !pi_lab_permission_system_enabled(session) { + return Ok(None); + } + let permission_dir = config_dir.join("extensions").join("pi-permission-system"); + fs::create_dir_all(&permission_dir).map_err(|error| { + WebError::internal(format!( + "创建 Pi permission-system config 目录失败: {error}" + )) + })?; + let mode = session_permission_mode(session).unwrap_or("confirm"); + let ( + default_path_rule, + allowed_root_rule, + default_mcp_rule, + read_rule, + write_rule, + edit_rule, + bash_rule, + ) = match mode { + "plan" => ("deny", "allow", "ask", "allow", "deny", "deny", "deny"), + "auto_edit" => ("ask", "allow", "allow", "allow", "allow", "allow", "ask"), + "full_access" => ( + "allow", "allow", "allow", "allow", "allow", "allow", "allow", + ), + _ => ("ask", "ask", "ask", "allow", "ask", "ask", "ask"), + }; + let mut path_rules = serde_json::Map::new(); + path_rules.insert("*".into(), json!(default_path_rule)); + path_rules.insert("*.env".into(), json!("deny")); + path_rules.insert("*.env.*".into(), json!("deny")); + path_rules.insert("*.env.example".into(), json!(default_path_rule)); + let mut external_rules = serde_json::Map::new(); + external_rules.insert("*".into(), json!(default_path_rule)); + if let Some(roots) = session + .allowed_roots_snapshot + .as_ref() + .and_then(|value| value.get("roots")) + .and_then(Value::as_array) + { + for root in roots { + if let Some(root_path) = root + .get("rootPath") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let pattern = format!( + "{}/*", + shell_glob_escape_path(root_path.trim_end_matches('/')) + ); + path_rules.insert( + shell_glob_escape_path(root_path.trim_end_matches('/')), + json!(allowed_root_rule), + ); + path_rules.insert(pattern.clone(), json!(allowed_root_rule)); + external_rules.insert( + shell_glob_escape_path(root_path.trim_end_matches('/')), + json!(allowed_root_rule), + ); + external_rules.insert(pattern, json!(allowed_root_rule)); + } + } + } + let mut permission_rules = serde_json::Map::new(); + permission_rules.insert("*".into(), json!("allow")); + permission_rules.insert("path".into(), Value::Object(path_rules)); + permission_rules.insert("external_directory".into(), Value::Object(external_rules)); + permission_rules.insert("read".into(), json!(read_rule)); + permission_rules.insert("write".into(), json!(write_rule)); + permission_rules.insert("edit".into(), json!(edit_rule)); + permission_rules.insert("hashline_edit".into(), json!(edit_rule)); + permission_rules.insert( + "grep".into(), + json!({ + "*": read_rule + }), + ); + permission_rules.insert( + "find".into(), + json!({ + "*": read_rule + }), + ); + permission_rules.insert( + "ls".into(), + json!({ + "*": read_rule + }), + ); + permission_rules.insert( + "bash".into(), + json!({ + "*": bash_rule, + "cat *": read_rule, + "head *": read_rule, + "tail *": read_rule, + "sed -n *": read_rule, + "rg *": read_rule, + "grep *": read_rule, + "ls *": read_rule, + "pwd": "allow", + "git status": read_rule, + "git diff *": read_rule, + "git ls-files *": read_rule, + "rm *": bash_rule, + "rm -r *": bash_rule, + "rm -rf *": bash_rule, + "mv *": bash_rule, + "cp *": bash_rule, + "mkdir *": bash_rule + }), + ); + permission_rules.insert( + "mcp".into(), + json!({ + "*": default_mcp_rule + }), + ); + for tool in pi_lab_tool_definitions_for_session(session) { + permission_rules.insert( + tool.pi_name.to_string(), + json!(pi_lab_session_tool_policy(session, tool.mnote_name)), + ); + } + let config = json!({ + "$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json", + "debugLog": false, + "permissionReviewLog": true, + "permission": Value::Object(permission_rules) + }); + let config_path = permission_dir.join("config.json"); + let pretty = serde_json::to_vec_pretty(&config).map_err(|error| { + WebError::internal(format!("序列化 Pi permission-system config 失败: {error}")) + })?; + fs::write(&config_path, pretty).map_err(|error| { + WebError::internal(format!("写入 Pi permission-system config 失败: {error}")) + })?; + Ok(Some(config_path)) +} + +fn pi_lab_mcp_enabled(session: &PiLabSession) -> bool { + let has_mcp_servers = session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("mcpServers")) + .and_then(Value::as_object) + .is_some_and(|servers| !servers.is_empty()); + if !has_mcp_servers { + return false; + } + let bridge_enabled_by_policy = session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("mcpBridge")) + .and_then(Value::as_str) + .is_some_and(|value| { + matches!( + value, + "pi-rust-sync-client" | "mnote-backend" | "pi-extension" + ) + }); + bridge_enabled_by_policy || pi_mcp_extension_source().is_some() +} + fn pi_lab_public_base_url() -> String { std::env::var("MNOTE_WEB_PUBLIC_BIND") .or_else(|_| std::env::var("MNOTE_WEB_BIND")) @@ -773,84 +1768,693 @@ fn pi_lab_public_base_url() -> String { .unwrap_or_else(|| "http://127.0.0.1:3000".into()) } -fn pi_lab_extension_tool_names() -> Vec<&'static str> { +#[derive(Debug, Clone)] +struct PiLabToolDefinition { + pi_name: &'static str, + mnote_name: &'static str, + label: &'static str, + description: &'static str, +} + +fn pi_lab_tool_definitions() -> Vec { vec![ - "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", + PiLabToolDefinition { + pi_name: "mnote_current_page_read", + mnote_name: "mnote.current_page.read", + label: "MNote current page read", + description: "Read the current MNote page through MNote access scope.", + }, + PiLabToolDefinition { + pi_name: "mnote_selection_read", + mnote_name: "mnote.selection.read", + label: "MNote selection read", + description: "Read the current MNote editor selection snapshot supplied by MNote.", + }, + PiLabToolDefinition { + pi_name: "mnote_allowed_roots_describe", + mnote_name: "mnote.allowed_roots.describe", + label: "MNote allowed roots describe", + description: "Describe MNote allowed roots and disabled raw tools.", + }, + PiLabToolDefinition { + pi_name: "mnote_local_file_read", + mnote_name: "mnote.local_file.read", + label: "MNote local file read", + description: "Read a file only through MNote allowed roots.", + }, + PiLabToolDefinition { + pi_name: "mnote_local_file_patch", + mnote_name: "mnote.local_file.patch", + label: "MNote local file patch", + description: "Patch a file only through MNote allowed roots and watcher refresh.", + }, + PiLabToolDefinition { + pi_name: "mnote_knowledge_rag_status", + mnote_name: "mnote.knowledge_rag.status", + label: "MNote LightRAG status", + description: "Check the MNote LightRAG knowledge provider status, indexed source registry, dashboard URL, and sync state. Use before knowledge-base questions when availability is uncertain. Params: optional workspaceId/rootUri; MNote fills the current Pi session context when omitted.", + }, + PiLabToolDefinition { + pi_name: "mnote_knowledge_rag_query", + mnote_name: "mnote.knowledge_rag.query", + label: "MNote LightRAG query", + description: "Ask the MNote LightRAG knowledge library across indexed books, papers, Office files, PDFs, images, and attachments. Use this for knowledge-base questions and answers that require sources, citations, or evidence. For book or long-document questions, pass query, mode='naive' or 'mix', topK, chunkTopK, includeChunkContent=true, and includeDocumentStructureIndex=true. Use returned references/citations quotes as evidence; do not invent page numbers or hand-write /documents/mnote:// links.", + }, + PiLabToolDefinition { + pi_name: "mnote_knowledge_rag_section_context", + mnote_name: "mnote.knowledge_rag.section_context", + label: "MNote LightRAG section context", + description: "Read bounded section blocks/chunks from a LightRAG sidecar using documentStructureIndex ranges returned by mnote_knowledge_rag_query. Use this for second-pass reading of large books or long documents when query references are not enough. Params include sourcePath/sourceId/lightRagDocId/filePath/sectionId, block or paragraph ordinal range, contextBefore/contextAfter, maxBlocks, maxChars.", + }, + PiLabToolDefinition { + pi_name: "mnote_knowledge_rag_open_reference", + mnote_name: "mnote.knowledge_rag.open_reference", + label: "MNote LightRAG open reference", + description: "Convert a LightRAG reference, filePath, or chunkId returned by mnote_knowledge_rag_query into a MNote clickable local resource locator. Use when the user asks to open or verify a cited source.", + }, + PiLabToolDefinition { + pi_name: "mnote_reference_open", + mnote_name: "mnote.reference.open", + label: "MNote reference open", + description: "Legacy alias for opening a citation/reference through MNote mapping. Prefer mnote_knowledge_rag_open_reference for LightRAG references.", + }, + PiLabToolDefinition { + pi_name: "mnote_codex_rescue_request", + mnote_name: "mnote.codex_rescue.request", + label: "MNote Codex rescue", + description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.", + }, + PiLabToolDefinition { + pi_name: "mnote_tool_receipt_write", + mnote_name: "mnote.tool_receipt.write", + label: "MNote tool receipt write", + description: "Write a provider-neutral MNote tool receipt.", + }, ] } -fn ensure_session_tool_bridge_extension(session: &PiLabSession) -> Result { - let extension_dir = PathBuf::from(&session.pi_session_dir).join("extensions"); - fs::create_dir_all(&extension_dir).map_err(|error| { +fn pi_lab_tool_definitions_for_session(session: &PiLabSession) -> Vec { + let Some(policy_tools) = session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("mnoteToolNames")) + .and_then(Value::as_array) + else { + return pi_lab_tool_definitions(); + }; + let allowed = policy_tools + .iter() + .filter_map(Value::as_str) + .collect::>(); + pi_lab_tool_definitions() + .into_iter() + .filter(|tool| allowed.contains(tool.mnote_name)) + .collect() +} + +fn pi_lab_extension_tool_names(session: &PiLabSession) -> Vec { + pi_lab_tool_definitions_for_session(session) + .into_iter() + .map(|tool| tool.pi_name.to_string()) + .collect() +} + +fn pi_lab_enabled_skill_sources(session: &PiLabSession) -> Vec { + session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("enabledSkillSources")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect() +} + +fn pi_lab_skill_cli_args(session: &PiLabSession) -> Vec { + let skill_sources = pi_lab_enabled_skill_sources(session); + if skill_sources.is_empty() { + return vec!["--no-skills".into()]; + } + skill_sources + .into_iter() + .flat_map(|source| ["--skill".to_string(), source]) + .collect() +} + +fn pi_lab_session_allows_mnote_tool(session: &PiLabSession, tool_name: &str) -> bool { + let Some(policy_tools) = session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("mnoteToolNames")) + .and_then(Value::as_array) + else { + return true; + }; + policy_tools + .iter() + .filter_map(Value::as_str) + .any(|name| name == tool_name) +} + +fn pi_lab_session_tool_policy(session: &PiLabSession, tool_name: &str) -> String { + let base_policy = session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("mnoteToolPolicies")) + .and_then(Value::as_object) + .and_then(|policies| policies.get(tool_name)) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| "allow".into()); + permission_mode_tool_policy(session_permission_mode(session), tool_name, &base_policy) +} + +fn pi_lab_extract_approval(params: &Value) -> Option<&serde_json::Map> { + params + .get("mnoteApproval") + .or_else(|| params.get("mnote_approval")) + .and_then(Value::as_object) +} + +fn cleanup_expired_approvals() { + let now = now_ms(); + if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() { + approvals.retain(|_, approval| approval.expires_at_ms >= now); + } + if let Ok(mut responses) = PI_LAB_UI_RESPONSES.lock() { + responses.retain(|_, response| response.expires_at_ms >= now); + } +} + +fn ui_response_key(session_id: &str, request_id: &str) -> String { + format!("{session_id}:{request_id}") +} + +fn record_pending_approval_from_event(session_id: &str, payload: &Value) { + if payload.get("type").and_then(Value::as_str) != Some("extension_ui_request") + || payload.get("method").and_then(Value::as_str) != Some("confirm") + { + return; + } + let Some(approval) = payload.get("mnoteApproval").and_then(Value::as_object) else { + return; + }; + let Some(approval_id) = approval + .get("approvalId") + .or_else(|| approval.get("approval_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + let Some(tool_name) = approval + .get("toolName") + .or_else(|| approval.get("tool_name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + let Some(params_hash) = approval + .get("paramsHash") + .or_else(|| approval.get("params_hash")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + cleanup_expired_approvals(); + if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() { + approvals.insert( + approval_key(session_id, approval_id), + PiLabPendingApproval { + session_id: session_id.into(), + approval_id: approval_id.into(), + tool_name: tool_name.into(), + params_hash: params_hash.into(), + confirmed: false, + cancelled: false, + expires_at_ms: now_ms().saturating_add(5 * 60 * 1000), + }, + ); + } +} + +fn update_pending_approval_response( + session_id: &str, + approval: Option<&Value>, + confirmed: bool, + cancelled: bool, +) { + let Some(approval) = approval.and_then(Value::as_object) else { + return; + }; + let Some(approval_id) = approval + .get("approvalId") + .or_else(|| approval.get("approval_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return; + }; + cleanup_expired_approvals(); + if let Ok(mut approvals) = PI_LAB_APPROVALS.lock() { + if let Some(pending) = approvals.get_mut(&approval_key(session_id, approval_id)) { + pending.confirmed = confirmed; + pending.cancelled = cancelled; + } + } +} + +fn confirm_pending_approval(session_id: &str, approval: Option<&Value>) { + update_pending_approval_response(session_id, approval, true, false); +} + +fn cancel_pending_approval(session_id: &str, approval: Option<&Value>) { + update_pending_approval_response(session_id, approval, false, true); +} + +fn pending_approval_response(session_id: &str, approval_id: &str) -> Option<(bool, bool)> { + cleanup_expired_approvals(); + PI_LAB_APPROVALS + .lock() + .ok() + .and_then(|approvals| { + approvals + .get(&approval_key(session_id, approval_id)) + .cloned() + }) + .map(|pending| (pending.confirmed, pending.cancelled)) +} + +fn pi_lab_tool_approval_confirmed( + session: Option<&PiLabSession>, + params: &Value, + tool_name: &str, + allow_bridge_approval: bool, +) -> bool { + let Some(session) = session else { + return false; + }; + if !allow_bridge_approval { + return false; + } + let Some(approval) = pi_lab_extract_approval(params) else { + return false; + }; + let confirmed = approval + .get("confirmed") + .or_else(|| approval.get("approved")) + .and_then(Value::as_bool) + .unwrap_or(false); + if !confirmed { + return false; + } + if !approval + .get("toolName") + .or_else(|| approval.get("tool_name")) + .and_then(Value::as_str) + .map(|name| name == tool_name) + .unwrap_or(true) + { + return false; + } + let Some(approval_id) = approval + .get("approvalId") + .or_else(|| approval.get("approval_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + let expected_params_hash = pi_lab_tool_params_hash(params); + cleanup_expired_approvals(); + PI_LAB_APPROVALS + .lock() + .ok() + .and_then(|approvals| { + approvals + .get(&approval_key(&session.session_id, approval_id)) + .cloned() + }) + .is_some_and(|pending| { + pending.session_id == session.session_id + && pending.approval_id == approval_id + && pending.tool_name == tool_name + && pending.params_hash == expected_params_hash + && pending.confirmed + && pending.expires_at_ms >= now_ms() + }) +} + +fn store_pending_ui_response( + session_id: &str, + request_id: &str, + value: Option, + confirmed: Option, + cancelled: bool, +) { + let key = ui_response_key(session_id, request_id); + if let Ok(mut responses) = PI_LAB_UI_RESPONSES.lock() { + responses.insert( + key, + PiLabUiPendingResponse { + value, + confirmed, + cancelled, + expires_at_ms: now_ms().saturating_add(120_000), + }, + ); + } +} + +fn take_pending_ui_response(session_id: &str, request_id: &str) -> Option { + let key = ui_response_key(session_id, request_id); + PI_LAB_UI_RESPONSES + .lock() + .ok() + .and_then(|mut responses| responses.remove(&key)) +} + +fn mnote_pi_extension_path() -> PathBuf { + if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MNOTE_EXTENSION") { + return PathBuf::from(path); + } + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../packages/pi-mnote/extensions/mnote-bridge.ts") +} + +fn mnote_pi_mcp_extension_path() -> PathBuf { + if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MCP_BUILTIN_EXTENSION") { + return PathBuf::from(path); + } + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../packages/pi-mnote/extensions/mnote-mcp/index.ts") +} + +fn mnote_pi_mcp_client_path() -> PathBuf { + if let Some(path) = env_trimmed("MNOTE_PAGE_AI_PI_MCP_CLIENT") { + return PathBuf::from(path); + } + mnote_pi_mcp_extension_path() + .parent() + .map(|path| path.join("client.mjs")) + .unwrap_or_else(|| PathBuf::from("client.mjs")) +} + +fn pi_rust_official_extension_path(name: &str) -> Option { + let file_name = match name { + "question" => "question.ts", + "questionnaire" => "questionnaire.ts", + "todo" => "todo.ts", + "permission-gate" => "permission-gate.ts", + "plan-mode" => "plan-mode/index.ts", + "subagent" => "subagent/index.ts", + _ => return None, + }; + let env_slug = name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_uppercase() + } else { + '_' + } + }) + .collect::(); + let env_name = format!("MNOTE_PAGE_AI_PI_OFFICIAL_{env_slug}_EXTENSION"); + if let Some(path) = env_trimmed(&env_name) { + return Some(PathBuf::from(path)); + } + Some( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../packages/pi-mnote/extensions/pi-rust-official") + .join(file_name), + ) +} + +fn pi_lab_resolve_bundled_pi_rust_official_source(source: &str) -> Option { + let name = source.strip_prefix("pi-rust-official:")?.trim(); + pi_rust_official_extension_path(name).map(|path| path.to_string_lossy().to_string()) +} + +fn pi_lab_official_extension_slug_from_source(source: &str) -> String { + source + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} + +fn copy_dir_recursive(source_dir: &Path, target_dir: &Path) -> Result<(), WebError> { + fs::create_dir_all(target_dir).map_err(|error| { WebError::internal(format!( - "创建 Pi Lab tool bridge extension 目录失败: {error}" + "创建 Pi 扩展 staging 目录失败: {}: {error}", + target_dir.display() )) })?; - let extension_path = extension_dir.join("mnote-tool-bridge.ts"); - let base_url = - serde_json::to_string(&pi_lab_public_base_url()).unwrap_or_else(|_| "\"\"".into()); - let session_id = serde_json::to_string(&session.session_id).unwrap_or_else(|_| "\"\"".into()); - let source = format!( - r#"import type {{ ExtensionAPI }} from "@earendil-works/pi-coding-agent"; -import {{ Type }} from "typebox"; + for entry in fs::read_dir(source_dir).map_err(|error| { + WebError::internal(format!( + "读取 Pi 扩展目录失败: {}: {error}", + source_dir.display() + )) + })? { + let entry = entry + .map_err(|error| WebError::internal(format!("读取 Pi 扩展目录项失败: {error}")))?; + let source_path = entry.path(); + let target_path = target_dir.join(entry.file_name()); + let file_type = entry.file_type().map_err(|error| { + WebError::internal(format!( + "读取 Pi 扩展目录项类型失败: {}: {error}", + source_path.display() + )) + })?; + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + } else if file_type.is_file() { + fs::copy(&source_path, &target_path).map_err(|error| { + WebError::internal(format!( + "复制 Pi 扩展文件失败: {} -> {}: {error}", + source_path.display(), + target_path.display() + )) + })?; + } + } + Ok(()) +} -const BASE_URL = {base_url}; -const SESSION_ID = {session_id}; -const BRIDGE_TOKEN = process.env.MNOTE_PI_LAB_BRIDGE_TOKEN || ""; - -async function callMnote(toolName: string, params: unknown) {{ - const response = await fetch(`${{BASE_URL}}/api/page-ai/pi/tool-call-bridge`, {{ - method: "POST", - headers: {{ - "content-type": "application/json", - "x-mnote-pi-lab-bridge-token": BRIDGE_TOKEN, - }}, - body: JSON.stringify({{ sessionId: SESSION_ID, toolName, params: params || {{}} }}), - }}); - const payload = await response.json().catch(() => ({{ ok: false, code: "bad_json" }})); - const text = JSON.stringify(payload.result || payload, null, 2); - return {{ - content: [{{ type: "text", text }}], - details: payload, - }}; -}} - -function register(pi: ExtensionAPI, name: string, label: string, description: string, toolName: string) {{ - pi.registerTool({{ - name, - label, - description, - promptSnippet: `${{label}}: ${{description}}`, - parameters: Type.Object({{}}, {{ additionalProperties: true }}), - async execute(_toolCallId, params) {{ - return callMnote(toolName, params); - }}, - }}); -}} - -export default function mnoteToolBridge(pi: ExtensionAPI) {{ - register(pi, "mnote_current_page_read", "MNote current page read", "Read the current MNote page through MNote access scope.", "mnote.current_page.read"); - register(pi, "mnote_selection_read", "MNote selection read", "Read the current MNote editor selection snapshot supplied by MNote.", "mnote.selection.read"); - register(pi, "mnote_allowed_roots_describe", "MNote allowed roots describe", "Describe MNote allowed roots and disabled raw tools.", "mnote.allowed_roots.describe"); - register(pi, "mnote_local_file_read", "MNote local file read", "Read a file only through MNote allowed roots.", "mnote.local_file.read"); - register(pi, "mnote_local_file_patch", "MNote local file patch", "Patch a file only through MNote allowed roots and watcher refresh.", "mnote.local_file.patch"); - register(pi, "mnote_knowledge_rag_query", "MNote LightRAG query", "Query LightRAG only through the MNote knowledge facade.", "mnote.knowledge_rag.query"); - register(pi, "mnote_reference_open", "MNote reference open", "Open a citation/reference through MNote mapping.", "mnote.reference.open"); - register(pi, "mnote_tool_receipt_write", "MNote tool receipt write", "Write a provider-neutral MNote tool receipt.", "mnote.tool_receipt.write"); -}} -"# - ); - fs::write(&extension_path, source.as_bytes()).map_err(|error| { - WebError::internal(format!("写入 Pi Lab tool bridge extension 失败: {error}")) +fn stage_extension_file( + source_path: &Path, + stage_root: &Path, + slug: &str, +) -> Result { + let file_name = source_path.file_name().ok_or_else(|| { + WebError::internal(format!("Pi 扩展路径缺少文件名: {}", source_path.display())) })?; - Ok(extension_path) + let target_dir = stage_root.join(slug); + fs::create_dir_all(&target_dir).map_err(|error| { + WebError::internal(format!( + "创建 Pi 扩展 staging 目录失败: {}: {error}", + target_dir.display() + )) + })?; + let target_path = target_dir.join(file_name); + fs::copy(source_path, &target_path).map_err(|error| { + WebError::internal(format!( + "复制 Pi 扩展文件失败: {} -> {}: {error}", + source_path.display(), + target_path.display() + )) + })?; + Ok(target_path.to_string_lossy().to_string()) +} + +fn pi_mnote_context_path(session: &PiLabSession) -> PathBuf { + PathBuf::from(&session.pi_session_dir) + .join("config") + .join("runtime-extensions") + .join("mnote-bridge") + .join("mnote-context.json") +} + +fn pi_mnote_context_payload( + session: &PiLabSession, + selected_context: Option<&Value>, + context_refs: Option<&[String]>, +) -> Value { + json!({ + "schema": "mnote.pi.context.v1", + "runtimeImplementation": pi_runtime_impl(), + "sessionId": session.session_id, + "rootUri": session.root_uri, + "workspaceId": session.workspace_id, + "pagePath": session.page_path, + "pageTitle": session.page_title, + "primaryRootPath": primary_allowed_root_path(session), + "allowedRoots": session.allowed_roots_snapshot, + "toolPolicies": mnote_pi_tool_policies(session), + "selectedContext": selected_context.cloned().unwrap_or(Value::Null), + "contextRefs": context_refs.unwrap_or(&[]), + "updatedAtMs": now_ms(), + }) +} + +fn write_pi_mnote_context_snapshot( + session: &PiLabSession, + selected_context: Option<&Value>, + context_refs: Option<&[String]>, +) -> Result { + let context_path = pi_mnote_context_path(session); + if let Some(parent) = context_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + WebError::internal(format!("创建 Pi Rust MNote context 目录失败: {error}")) + })?; + } + let payload = pi_mnote_context_payload(session, selected_context, context_refs); + let bytes = serde_json::to_vec_pretty(&payload).map_err(|error| { + WebError::internal(format!("序列化 Pi Rust MNote context 失败: {error}")) + })?; + let temp_path = context_path.with_extension("json.tmp"); + fs::write(&temp_path, bytes) + .map_err(|error| WebError::internal(format!("写入 Pi Rust MNote context 失败: {error}")))?; + fs::rename(&temp_path, &context_path) + .map_err(|error| WebError::internal(format!("提交 Pi Rust MNote context 失败: {error}")))?; + Ok(context_path) +} + +fn pi_mnote_input_context_prefix( + session: &PiLabSession, + selected_context: Option<&Value>, + context_refs: Option<&[String]>, +) -> Result { + let payload = pi_mnote_context_payload(session, selected_context, context_refs); + let bytes = serde_json::to_vec(&payload).map_err(|error| { + WebError::internal(format!("序列化 Pi Rust input context 失败: {error}")) + })?; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").map_err(|error| { + WebError::internal(format!("编码 Pi Rust input context 失败: {error}")) + })?; + } + Ok(format!("[[MNOTE_PI_CONTEXT_V1:{encoded}]]\n")) +} + +fn stage_extension_tree( + entry_path: &Path, + stage_root: &Path, + slug: &str, +) -> Result { + let source_dir = entry_path.parent().ok_or_else(|| { + WebError::internal(format!("Pi 扩展入口缺少父目录: {}", entry_path.display())) + })?; + let file_name = entry_path.file_name().ok_or_else(|| { + WebError::internal(format!("Pi 扩展入口缺少文件名: {}", entry_path.display())) + })?; + let target_dir = stage_root.join(slug); + copy_dir_recursive(source_dir, &target_dir)?; + Ok(target_dir.join(file_name).to_string_lossy().to_string()) +} + +fn stage_pi_extension_source(source: &str, stage_root: &Path) -> Result { + let source_path = PathBuf::from(source); + if !source_path.exists() { + return Ok(source.to_string()); + } + let slug = pi_lab_official_extension_slug_from_source(source); + if source_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "index.ts" || name == "index.js") + { + return stage_extension_tree(&source_path, stage_root, &slug); + } + stage_extension_file(&source_path, stage_root, &slug) +} + +fn stage_pi_lab_runtime_extension_sources( + session: &PiLabSession, + stage_root: &Path, +) -> Result, WebError> { + let mut staged = Vec::new(); + for source in pi_lab_runtime_extension_sources(session) { + staged.push(stage_pi_extension_source(&source, stage_root)?); + } + staged.sort(); + staged.dedup(); + Ok(staged) +} + +fn stage_project_mcp_config_for_extension( + extension_source: Option<&str>, + mcp_config_path: &Option, +) -> Result<(), WebError> { + let (Some(extension_source), Some(mcp_config_path)) = (extension_source, mcp_config_path) + else { + return Ok(()); + }; + let extension_dir = Path::new(extension_source).parent().ok_or_else(|| { + WebError::internal(format!("Pi MCP 扩展入口缺少父目录: {extension_source}")) + })?; + let project_mcp_dir = extension_dir.join(".pi"); + fs::create_dir_all(&project_mcp_dir).map_err(|error| { + WebError::internal(format!( + "创建 Pi MCP project config 目录失败: {}: {error}", + project_mcp_dir.display() + )) + })?; + fs::copy(mcp_config_path, project_mcp_dir.join("mcp.json")).map_err(|error| { + WebError::internal(format!( + "写入 Pi MCP project config 失败: {}: {error}", + project_mcp_dir.display() + )) + })?; + Ok(()) +} + +fn mnote_pi_tool_manifest(session: &PiLabSession) -> Value { + Value::Array( + pi_lab_tool_definitions_for_session(session) + .into_iter() + .map(|tool| { + json!({ + "piName": tool.pi_name, + "mnoteName": tool.mnote_name, + "label": tool.label, + "description": tool.description, + }) + }) + .collect(), + ) +} + +fn mnote_pi_tool_policies(session: &PiLabSession) -> Value { + let mut policies = serde_json::Map::new(); + for tool in pi_lab_tool_definitions_for_session(session) { + policies.insert( + tool.mnote_name.to_string(), + json!(pi_lab_session_tool_policy(session, tool.mnote_name)), + ); + } + Value::Object(policies) } fn publish_event(session_id: &str, kind: &str, payload: Value) { @@ -889,6 +2493,64 @@ fn get_session(session_id: &str) -> Option { .and_then(|sessions| sessions.get(session_id).cloned()) } +fn session_has_process(session_id: &str) -> bool { + PI_LAB_PROCESSES + .lock() + .map(|processes| processes.contains_key(session_id)) + .unwrap_or(false) +} + +fn session_runtime_is_usable(session: &PiLabSession) -> bool { + matches!( + session.status, + PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning + ) && (session.runtime_mode == "mock" || session_has_process(&session.session_id)) +} + +fn session_runtime_config_matches(existing: &PiLabSession, requested: &PiLabSession) -> bool { + existing.model_provider == requested.model_provider + && existing.model_id == requested.model_id + && existing.thinking_level == requested.thinking_level + && session_permission_mode(existing) == session_permission_mode(requested) +} + +fn refresh_runtime_session_context(existing: &mut PiLabSession, requested: &PiLabSession) { + existing.root_uri = requested.root_uri.clone(); + existing.workspace_id = requested.workspace_id.clone(); + existing.page_path = requested.page_path.clone(); + existing.page_title = requested.page_title.clone(); + existing.allowed_roots_snapshot = requested.allowed_roots_snapshot.clone(); + existing.runtime_policy_snapshot = requested.runtime_policy_snapshot.clone(); + existing.runtime_mode = requested.runtime_mode.clone(); + existing.runtime_error = None; +} + +fn remove_session(session_id: &str) -> bool { + PI_LAB_SESSIONS + .lock() + .map(|mut sessions| sessions.remove(session_id).is_some()) + .unwrap_or(false) +} + +async fn kill_process_handle(handle: PiLabProcessHandle) { + let mut child = handle.child.lock().await; + let _ = child.kill().await; + let _ = child.wait().await; +} + +async fn kill_session_process(session_id: &str) -> bool { + let handle = PI_LAB_PROCESSES + .lock() + .ok() + .and_then(|mut processes| processes.remove(session_id)); + if let Some(handle) = handle { + kill_process_handle(handle).await; + true + } else { + false + } +} + fn cleanup_expired_sessions() { let cutoff = now_ms().saturating_sub(PI_LAB_SESSION_TTL_MS); let expired = PI_LAB_SESSIONS @@ -938,11 +2600,17 @@ fn cleanup_expired_sessions() { }) .unwrap_or_default(); if !expired.is_empty() { + let mut handles = Vec::new(); if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { for (session_id, _) in &expired { - processes.remove(session_id); + if let Some(handle) = processes.remove(session_id) { + handles.push(handle); + } } } + for handle in handles { + tokio::spawn(kill_process_handle(handle)); + } for (_, session_dir) in expired { let path = PathBuf::from(session_dir); if path @@ -1031,8 +2699,70 @@ async fn start_runtime_for_session( fs::create_dir_all(&session.pi_session_dir) .map_err(|error| WebError::internal(format!("创建 Pi Lab sessionDir 失败: {error}")))?; let pi_config_dir = ensure_session_models_config(&session)?; - let bridge_extension_path = ensure_session_tool_bridge_extension(&session)?; - let mnote_tool_names = pi_lab_extension_tool_names(); + let mcp_config_path = ensure_session_mcp_config(&session, &pi_config_dir)?; + let shared_mcp_cache_path = hydrate_session_mcp_cache(&session, &pi_config_dir)?; + let permission_config_path = ensure_session_pi_permission_config(&session, &pi_config_dir)?; + let mnote_pi_extension_source_path = mnote_pi_extension_path(); + if !mnote_pi_extension_source_path.exists() { + return Err(WebError::internal(format!( + "MNote Pi package extension 不存在: {}", + mnote_pi_extension_source_path.display() + ))); + } + let extension_stage_dir = pi_config_dir.join("runtime-extensions"); + let mnote_pi_extension_path = stage_extension_file( + &mnote_pi_extension_source_path, + &extension_stage_dir, + "mnote-bridge", + )?; + let mnote_context_path = write_pi_mnote_context_snapshot(&session, None, None)?; + let enabled_builtin_tools = pi_lab_enabled_builtin_tools(&session); + let configured_extension_sources = pi_lab_configured_extension_sources(&session); + let mut pi_extension_sources = + stage_pi_lab_runtime_extension_sources(&session, &extension_stage_dir)?; + let mut mcp_extension_runtime_path = None; + if pi_lab_mcp_enabled(&session) { + let mcp_extension_source = if let Some(custom_source) = pi_mcp_extension_source() { + let allowlist = pi_lab_external_extension_allowlist(); + if !pi_lab_external_extensions_enabled(&session) + || !allowlist.iter().any(|allowed| allowed == &custom_source) + { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_mcp_extension_not_allowed", + "自定义 Pi MCP 扩展必须显式启用外部扩展,并加入 MNOTE_PAGE_AI_PI_EXTENSION_ALLOWLIST。", + )); + } + PathBuf::from(custom_source) + } else { + mnote_pi_mcp_extension_path() + }; + if !mcp_extension_source.exists() { + return Err(WebError::internal(format!( + "MNote Pi Rust MCP extension 不存在: {}", + mcp_extension_source.display() + ))); + } + let staged_mcp_extension = stage_pi_extension_source( + &mcp_extension_source.to_string_lossy(), + &extension_stage_dir, + )?; + pi_extension_sources.push(staged_mcp_extension.clone()); + mcp_extension_runtime_path = Some(staged_mcp_extension); + pi_extension_sources.sort(); + pi_extension_sources.dedup(); + } + stage_project_mcp_config_for_extension( + mcp_extension_runtime_path.as_deref(), + &mcp_config_path, + )?; + let bridge_base_url = pi_lab_public_base_url(); + let pi_extension_tool_names = pi_lab_runtime_pi_extension_tool_names(&session); + let mut pi_tool_names = pi_lab_extension_tool_names(&session); + pi_tool_names.extend(pi_extension_tool_names.clone()); + pi_tool_names.extend(enabled_builtin_tools.clone()); + pi_tool_names.sort(); + pi_tool_names.dedup(); if session.runtime_mode == "mock" { session.status = PiLabSessionStatus::RuntimeRunning; session.runtime_pid = None; @@ -1047,6 +2777,16 @@ async fn start_runtime_for_session( "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, + "runtimePolicy": session.runtime_policy_snapshot, + "mcpConfigPath": mcp_config_path.clone(), + "sharedMcpCachePath": shared_mcp_cache_path.clone(), + "permissionConfigPath": permission_config_path.clone(), + "configuredPiExtensionSources": configured_extension_sources.clone(), + "piExtensionSources": pi_extension_sources.clone(), + "piExtensionToolNames": pi_extension_tool_names.clone(), + "mnotePiToolManifest": mnote_pi_tool_manifest(&session), + "piToolNames": pi_tool_names.clone(), + "managedBuiltinTools": enabled_builtin_tools.clone(), }), )?; publish_event( @@ -1057,32 +2797,66 @@ async fn start_runtime_for_session( "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, + "runtimePolicy": session.runtime_policy_snapshot, "piCodingAgentDir": pi_config_dir, - "mnoteToolBridgeExtension": bridge_extension_path, - "mnoteToolNames": mnote_tool_names, + "mnotePiExtension": mnote_pi_extension_path, + "mcpConfigPath": mcp_config_path, + "sharedMcpCachePath": shared_mcp_cache_path, + "permissionConfigPath": permission_config_path, + "configuredPiExtensionSources": configured_extension_sources, + "piExtensionSources": pi_extension_sources, + "piExtensionToolNames": pi_extension_tool_names, + "mnotePiToolManifest": mnote_pi_tool_manifest(&session), + "piToolNames": pi_tool_names, + "managedBuiltinTools": enabled_builtin_tools, }), ); return Ok(session); } - let mut command = Command::new(pi_binary()); + let runtime_impl = pi_runtime_impl(); + let binary = pi_binary(); + if !pi_runtime_binary_available(&binary) { + let message = pi_runtime_install_hint(&runtime_impl, &binary) + .unwrap_or_else(|| format!("Pi runtime 不可用: {binary}")); + session.status = PiLabSessionStatus::Error; + session.runtime_error = Some(message.clone()); + upsert_session(session.clone()); + let _ = persist_upsert_run(state, &session); + return Err( + WebError::bad_gateway_code("page_ai_pi_lab_runtime_missing", message).with_details( + json!({ + "runtimeImplementation": runtime_impl, + "runtimeBinary": binary, + }), + ), + ); + } + let mut command = Command::new(&binary); command .arg("--mode") .arg("rpc") .arg("--session-dir") .arg(&session.pi_session_dir) .arg("--no-approve") - .arg("--no-builtin-tools") .arg("--no-extensions") .arg("--extension") - .arg(&bridge_extension_path) + .arg(&mnote_pi_extension_path) .arg("--tools") - .arg(mnote_tool_names.join(",")) - .arg("--no-skills") + .arg(pi_tool_names.join(",")) .arg("--no-prompt-templates") .arg("--no-context-files") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if let Some(cwd) = primary_allowed_root_path(&session) { + command.current_dir(cwd); + } + for source in &pi_extension_sources { + command.arg("--extension").arg(source); + } + for arg in pi_lab_skill_cli_args(&session) { + command.arg(arg); + } if let Some(provider) = session .model_provider .as_deref() @@ -1097,6 +2871,13 @@ async fn start_runtime_for_session( { command.arg("--model").arg(model); } + if let Some(thinking) = session + .thinking_level + .as_deref() + .filter(|value| !value.is_empty()) + { + command.arg("--thinking").arg(thinking); + } command .arg("--name") .arg(format!("MNote Pi Lab {}", session.session_id)); @@ -1107,18 +2888,56 @@ async fn start_runtime_for_session( "PI_CODING_AGENT_DIR", pi_config_dir.to_string_lossy().to_string(), ); + let bridge_tools = + serde_json::to_string(&mnote_pi_tool_manifest(&session)).unwrap_or_else(|_| "[]".into()); + let bridge_tool_policies = + serde_json::to_string(&mnote_pi_tool_policies(&session)).unwrap_or_else(|_| "{}".into()); + command.env("PI_MNOTE_RUNTIME_IMPL", &runtime_impl); + command.env("PI_MNOTE_CONTEXT_FILE", &mnote_context_path); + command.env("PI_MNOTE_HTTP_BRIDGE_AVAILABLE", "0"); + command.env("PI_MNOTE_BRIDGE_BASE_URL", &bridge_base_url); + command.env("PI_MNOTE_BRIDGE_SESSION_ID", &session.session_id); + command.env("PI_MNOTE_BRIDGE_TOOLS", &bridge_tools); + command.env("PI_MNOTE_BRIDGE_TOOL_POLICIES", &bridge_tool_policies); + command.env("PI_MNOTE_BRIDGE_TOKEN", &session.bridge_token); + command.env("MNOTE_PI_BRIDGE_BASE_URL", &bridge_base_url); + command.env("MNOTE_PI_BRIDGE_SESSION_ID", &session.session_id); + command.env("MNOTE_PI_BRIDGE_TOOLS", &bridge_tools); + command.env("MNOTE_PI_BRIDGE_TOOL_POLICIES", &bridge_tool_policies); + command.env("MNOTE_PI_BRIDGE_TOKEN", &session.bridge_token); command.env("MNOTE_PI_LAB_BRIDGE_TOKEN", &session.bridge_token); + if let Some(permission_mode) = session_permission_mode(&session) { + command.env("MNOTE_PI_PERMISSION_MODE", permission_mode); + } + command.env("MNOTE_PI_RUNTIME_BINARY", &binary); + command.env("PI_HTTP_ALLOW_LOOPBACK", "1"); + if pi_lab_mcp_enabled(&session) { + // Pi Rust 的同步 child_process API 还有独立的默认关闭开关。 + // 仅受控 MCP 扩展启用时开放,后续仍经过扩展 exec capability/mediation。 + command.env("PIJS_ALLOW_UNSAFE_SYNC_EXEC", "1"); + } command.env("OPENAI_BASE_URL", omniroute_base_url()); if let Some(key) = omniroute_api_key() { command.env("OPENAI_API_KEY", key); } - let mut child = command.spawn().map_err(|error| { - WebError::bad_gateway_code( - "page_ai_pi_lab_runtime_spawn_failed", - format!("启动 pi --mode rpc 失败: {error}"), - ) - })?; + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + let message = format!("启动 {binary} --mode rpc 失败: {error}"); + session.status = PiLabSessionStatus::Error; + session.runtime_error = Some(message.clone()); + upsert_session(session.clone()); + let _ = persist_upsert_run(state, &session); + return Err( + WebError::bad_gateway_code("page_ai_pi_lab_runtime_spawn_failed", message) + .with_details(json!({ + "runtimeImplementation": runtime_impl, + "runtimeBinary": binary, + })), + ); + } + }; let pid = child.id(); let stdin = child.stdin.take().ok_or_else(|| { WebError::bad_gateway_code("page_ai_pi_lab_stdin_missing", "Pi RPC stdin 不可用") @@ -1126,6 +2945,9 @@ async fn start_runtime_for_session( let stdout = child.stdout.take().ok_or_else(|| { WebError::bad_gateway_code("page_ai_pi_lab_stdout_missing", "Pi RPC stdout 不可用") })?; + let stderr = child.stderr.take().ok_or_else(|| { + WebError::bad_gateway_code("page_ai_pi_lab_stderr_missing", "Pi RPC stderr 不可用") + })?; let handle = PiLabProcessHandle { stdin: Arc::new(AsyncMutex::new(stdin)), @@ -1135,25 +2957,41 @@ async fn start_runtime_for_session( if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { processes.insert(session.session_id.clone(), handle); } + if let Some(shared_mcp_cache_path) = shared_mcp_cache_path.clone() { + schedule_mcp_cache_sync( + session.session_id.clone(), + pi_config_dir.clone(), + shared_mcp_cache_path, + ); + } session.status = PiLabSessionStatus::RuntimeRunning; session.runtime_pid = pid; + session.runtime_error = None; upsert_session(session.clone()); publish_event( &session.session_id, "runtime_started", json!({ "mode": "rpc", + "runtimeImplementation": runtime_impl, "pid": pid, "sessionDir": session.pi_session_dir, "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, + "thinkingLevel": session.thinking_level, "omnirouteBaseUrl": omniroute_base_url(), "piCodingAgentDir": pi_config_dir, - "mnoteToolBridgeExtension": bridge_extension_path, - "mnoteToolNames": mnote_tool_names, - "disabledBuiltinTools": ["bash", "read", "write", "edit"], + "mnotePiExtension": mnote_pi_extension_path, + "mcpConfigPath": mcp_config_path, + "permissionConfigPath": permission_config_path, + "configuredPiExtensionSources": configured_extension_sources, + "piExtensionSources": pi_extension_sources, + "piExtensionToolNames": pi_extension_tool_names, + "mnotePiToolManifest": mnote_pi_tool_manifest(&session), + "piToolNames": pi_tool_names, + "managedBuiltinTools": enabled_builtin_tools, }), ); @@ -1202,15 +3040,49 @@ async fn start_runtime_for_session( session.status = PiLabSessionStatus::RuntimeRunning; }); } + record_pending_approval_from_event(&session_id, &payload); persist_event("pi_rpc_event", &payload); publish_event(&session_id, "pi_rpc_event", payload); } Ok(None) => { - update_session(&session_id, |session| { - session.status = PiLabSessionStatus::Idle; + let closed_during_turn = get_session(&session_id) + .map(|session| session.status == PiLabSessionStatus::TurnRunning) + .unwrap_or(false); + let close_payload = json!({ + "pid": pid, + "duringTurn": closed_during_turn, + "message": if closed_during_turn { + "Pi Rust runtime exited before returning an assistant response" + } else { + "Pi Rust runtime stdout closed" + }, }); - persist_event("runtime_stdout_closed", &json!({})); - publish_event(&session_id, "runtime_stdout_closed", json!({})); + update_session(&session_id, |session| { + session.runtime_pid = None; + match session.status { + PiLabSessionStatus::TurnRunning => { + session.status = PiLabSessionStatus::Error; + session.runtime_error = Some( + "Pi Rust runtime exited before returning an assistant response" + .into(), + ); + } + PiLabSessionStatus::Aborted | PiLabSessionStatus::Error => {} + PiLabSessionStatus::Idle | PiLabSessionStatus::RuntimeRunning => { + session.status = PiLabSessionStatus::Idle; + } + } + }); + if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { + if processes + .get(&session_id) + .is_some_and(|handle| handle._pid == pid) + { + processes.remove(&session_id); + } + } + persist_event("runtime_stdout_closed", &close_payload); + publish_event(&session_id, "runtime_stdout_closed", close_payload); break; } Err(error) => { @@ -1230,6 +3102,57 @@ async fn start_runtime_for_session( } }); + let stderr_state = state.clone(); + let stderr_session = session.clone(); + let stderr_session_id = stderr_session.session_id.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + let payload = json!({"line": line}); + if let Err(error) = persist_append_event( + &stderr_state, + &stderr_session, + "runtime_stderr_line", + &payload, + ) { + publish_event( + &stderr_session_id, + "runtime_persistence_error", + json!({ + "error": error.message(), + "eventType": "runtime_stderr_line", + }), + ); + } + publish_event(&stderr_session_id, "runtime_stderr_line", payload); + } + Ok(None) => break, + Err(error) => { + let payload = json!({"error": error.to_string()}); + if let Err(error) = persist_append_event( + &stderr_state, + &stderr_session, + "runtime_stderr_error", + &payload, + ) { + publish_event( + &stderr_session_id, + "runtime_persistence_error", + json!({ + "error": error.message(), + "eventType": "runtime_stderr_error", + }), + ); + } + publish_event(&stderr_session_id, "runtime_stderr_error", payload); + break; + } + } + } + }); + persist_upsert_run(state, &session)?; persist_append_event( state, @@ -1237,10 +3160,23 @@ async fn start_runtime_for_session( "runtime_started", &json!({ "mode": "rpc", + "runtimeImplementation": pi_runtime_impl(), "pid": session.runtime_pid, "providerSessionId": session.provider_session_id, "modelProvider": session.model_provider, "modelId": session.model_id, + "thinkingLevel": session.thinking_level, + "omnirouteBaseUrl": omniroute_base_url(), + "sessionDir": session.pi_session_dir, + "mnotePiExtension": mnote_pi_extension_path, + "mcpConfigPath": mcp_config_path, + "permissionConfigPath": permission_config_path, + "configuredPiExtensionSources": pi_lab_configured_extension_sources(&session), + "piExtensionSources": pi_extension_sources, + "piExtensionToolNames": pi_extension_tool_names, + "mnotePiToolManifest": mnote_pi_tool_manifest(&session), + "piToolNames": pi_tool_names, + "managedBuiltinTools": pi_lab_enabled_builtin_tools(&session), }), )?; Ok(session) @@ -1484,7 +3420,9 @@ fn receipt_for( PiLabToolReceipt { schema: PI_LAB_SCHEMA_RECEIPT, receipt_id: generate_id("pi_receipt"), - mnote_user_id: context.auth.actor_id.clone(), + mnote_user_id: session + .map(|session| session.mnote_user_id.clone()) + .unwrap_or_else(|| context.auth.actor_id.clone()), workspace_id: session.and_then(|session| session.workspace_id.clone()), root_uri: session.and_then(|session| session.root_uri.clone()), page_path: session.and_then(|session| session.page_path.clone()), @@ -1514,6 +3452,78 @@ pub struct PiLabToolFacade { session: Option, } +fn string_param(params: &Value, key: &str) -> Option { + params + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn u64_param(params: &Value, key: &str) -> Option { + params.get(key).and_then(|value| { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|n| (n >= 0).then_some(n as u64))) + .or_else(|| { + value + .as_str() + .and_then(|text| text.trim().parse::().ok()) + }) + }) +} + +fn bool_param(params: &Value, key: &str) -> Option { + params.get(key).and_then(|value| { + value.as_bool().or_else(|| { + value + .as_str() + .and_then(|text| match text.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + }) + }) + }) +} + +fn truncate_text(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut text = value.chars().take(max_chars).collect::(); + text.push_str("\n...[truncated]"); + text +} + +fn mnote_repo_root() -> PathBuf { + if let Ok(root) = std::env::var("MNOTE_REPO_ROOT") { + let root = PathBuf::from(root); + if root.exists() { + return root; + } + } + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + for ancestor in manifest_dir.ancestors() { + if ancestor.join("AGENTS.md").exists() && ancestor.join("rust").exists() { + return ancestor.to_path_buf(); + } + } + std::env::current_dir().unwrap_or(manifest_dir) +} + +fn pi_codex_extra_writable_dirs(repo_root: &Path) -> Vec { + [ + repo_root.join(".codex").join("skills"), + PathBuf::from("/home/lix/.codex/skills"), + PathBuf::from("/home/lix/.agents/skills"), + ] + .into_iter() + .filter(|path| path.exists()) + .collect() +} + impl PiLabToolFacade { fn session_root_uri(&self, params: &Value) -> Option { params @@ -1595,6 +3605,38 @@ impl PiLabToolFacade { fn allowed_roots_describe(&self) -> Result { let roots = active_allowed_roots(&self.state, &self.context)?; + let managed_builtin_tools = self + .session + .as_ref() + .map(pi_lab_enabled_builtin_tools) + .unwrap_or_default(); + let denied_builtin_tools = if managed_builtin_tools.is_empty() { + pi_lab_managed_builtin_tools() + } else { + Vec::new() + }; + let permission_provider = if managed_builtin_tools.is_empty() { + "mnote-bridge-rust-policy" + } else if self + .session + .as_ref() + .is_some_and(pi_lab_official_permission_gate_enabled) + { + "pi-rust-official:permission-gate" + } else { + "@gotgenes/pi-permission-system" + }; + let permission_note = if managed_builtin_tools.is_empty() { + "Pi built-in read/write/edit/hashline_edit/bash/grep/find/ls are disabled by default for Pi Rust; MNote bridge tools enforce allowed roots in Rust." + } else if self + .session + .as_ref() + .is_some_and(pi_lab_official_permission_gate_enabled) + { + "Pi Rust official permission-gate only confirms dangerous bash commands; MNote keeps built-in file tools disabled unless a separate explicit permission-system bridge is enabled." + } else { + "Pi built-in read/write/edit/hashline_edit/bash/grep/find/ls are exposed through opt-in pi-permission-system path gates." + }; Ok(json!({ "allowedRoots": roots.iter().map(|root| json!({ "rootUri": root.root_uri, @@ -1603,15 +3645,22 @@ impl PiLabToolFacade { "permission": root.permission, "source": root.source, })).collect::>(), - "deniedPiBuiltinTools": ["bash", "read", "write", "edit"], + "deniedPiBuiltinTools": denied_builtin_tools, + "managedPiBuiltinTools": managed_builtin_tools, + "permissionProvider": permission_provider, + "note": permission_note, "mnoteTools": [ "mnote.current_page.read", "mnote.selection.read", "mnote.allowed_roots.describe", "mnote.local_file.read", "mnote.local_file.patch", + "mnote.knowledge_rag.status", "mnote.knowledge_rag.query", + "mnote.knowledge_rag.section_context", + "mnote.knowledge_rag.open_reference", "mnote.reference.open", + "mnote.codex_rescue.request", "mnote.tool_receipt.write" ], })) @@ -1756,6 +3805,96 @@ impl PiLabToolFacade { Ok(payload) } + async fn knowledge_rag_status(&self, params: Value) -> Result { + let query = knowledge_rag::KnowledgeRagStatusQuery { + workspace_id: self + .session + .as_ref() + .and_then(|session| session.workspace_id.clone()) + .or_else(|| { + params + .get("workspaceId") + .and_then(Value::as_str) + .map(str::to_string) + }), + root_uri: self.session_root_uri(¶ms), + }; + let Json(payload) = knowledge_rag::status( + State(self.state.clone()), + Extension(self.context.clone()), + Query(query), + ) + .await?; + Ok(payload) + } + + async fn knowledge_rag_section_context(&self, params: Value) -> Result { + let root_uri = self.session_root_uri(¶ms).ok_or_else(|| { + WebError::bad_request_code( + "page_ai_pi_lab_root_uri_required", + "读取 RAG section context 缺少 rootUri", + ) + })?; + let body = knowledge_rag::KnowledgeRagSectionContextRequest { + workspace_id: self + .session + .as_ref() + .and_then(|session| session.workspace_id.clone()) + .or_else(|| { + params + .get("workspaceId") + .and_then(Value::as_str) + .map(str::to_string) + }), + root_uri, + source_path: string_param(¶ms, "sourcePath") + .or_else(|| string_param(¶ms, "source_path")), + source_id: string_param(¶ms, "sourceId") + .or_else(|| string_param(¶ms, "source_id")), + light_rag_doc_id: string_param(¶ms, "lightRagDocId") + .or_else(|| string_param(¶ms, "light_rag_doc_id")), + provider_knowledge_id: string_param(¶ms, "providerKnowledgeId") + .or_else(|| string_param(¶ms, "provider_knowledge_id")), + provider_knowledge_base_id: string_param(¶ms, "providerKnowledgeBaseId") + .or_else(|| string_param(¶ms, "provider_knowledge_base_id")), + chunk_id: string_param(¶ms, "chunkId") + .or_else(|| string_param(¶ms, "chunk_id")), + provider_chunk_id: string_param(¶ms, "providerChunkId") + .or_else(|| string_param(¶ms, "provider_chunk_id")), + file_path: string_param(¶ms, "filePath") + .or_else(|| string_param(¶ms, "file_path")), + section_id: string_param(¶ms, "sectionId") + .or_else(|| string_param(¶ms, "section_id")), + start_block_ordinal: u64_param(¶ms, "startBlockOrdinal") + .or_else(|| u64_param(¶ms, "start_block_ordinal")), + end_block_ordinal: u64_param(¶ms, "endBlockOrdinal") + .or_else(|| u64_param(¶ms, "end_block_ordinal")), + start_paragraph_ordinal: u64_param(¶ms, "startParagraphOrdinal") + .or_else(|| u64_param(¶ms, "start_paragraph_ordinal")) + .map(|value| value as u32), + end_paragraph_ordinal: u64_param(¶ms, "endParagraphOrdinal") + .or_else(|| u64_param(¶ms, "end_paragraph_ordinal")) + .map(|value| value as u32), + context_before: u64_param(¶ms, "contextBefore") + .or_else(|| u64_param(¶ms, "context_before")), + context_after: u64_param(¶ms, "contextAfter") + .or_else(|| u64_param(¶ms, "context_after")), + max_blocks: u64_param(¶ms, "maxBlocks") + .or_else(|| u64_param(¶ms, "max_blocks")) + .map(|value| value as usize), + max_chars: u64_param(¶ms, "maxChars") + .or_else(|| u64_param(¶ms, "max_chars")) + .map(|value| value as usize), + }; + let Json(payload) = knowledge_rag::section_context( + State(self.state.clone()), + Extension(self.context.clone()), + Json(body), + ) + .await?; + Ok(payload) + } + async fn reference_open(&self, params: Value) -> Result { let root_uri = self.session_root_uri(¶ms).ok_or_else(|| { WebError::bad_request_code("page_ai_pi_lab_root_uri_required", "打开引用缺少 rootUri") @@ -1798,6 +3937,179 @@ impl PiLabToolFacade { .await?; Ok(payload) } + + async fn codex_rescue_request(&self, params: Value) -> Result { + if !local_folder_source::is_local_access_policy_admin_context(&self.context) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_codex_rescue_admin_required", + "Codex 自救只能由管理员授权的 Pi 会话调用", + ) + .with_context(&self.context)); + } + let issue = string_param(¶ms, "issue") + .or_else(|| string_param(¶ms, "problem")) + .or_else(|| string_param(¶ms, "question")) + .ok_or_else(|| { + WebError::bad_request_code( + "page_ai_pi_lab_codex_rescue_issue_required", + "Codex 自救需要提供 issue/problem/question", + ) + })?; + let evidence = string_param(¶ms, "evidence") + .or_else(|| string_param(¶ms, "logs")) + .or_else(|| string_param(¶ms, "context")) + .unwrap_or_default(); + let attempted = string_param(¶ms, "attempted") + .or_else(|| string_param(¶ms, "attemptedSteps")) + .unwrap_or_default(); + let desired = string_param(¶ms, "desiredOutcome") + .or_else(|| string_param(¶ms, "goal")) + .unwrap_or_else(|| "修复根因;如果不能安全修复,说明阻塞原因和下一步".into()); + let allow_writes = bool_param(¶ms, "allowWrites").unwrap_or(true); + let timeout_secs = u64_param(¶ms, "timeoutSeconds") + .unwrap_or(300) + .clamp(60, 900); + let rescue_id = generate_id("codex_rescue"); + let repo_root = mnote_repo_root(); + let output_dir = self + .session + .as_ref() + .map(|session| PathBuf::from(&session.pi_session_dir)) + .unwrap_or_else(std::env::temp_dir) + .join("codex-rescue"); + fs::create_dir_all(&output_dir) + .map_err(|error| WebError::internal(format!("创建 Codex 自救输出目录失败: {error}")))?; + let final_message_path = output_dir.join(format!("{rescue_id}-final.md")); + let sandbox = if allow_writes { + "workspace-write" + } else { + "read-only" + }; + let root_uri = self + .session + .as_ref() + .and_then(|session| session.root_uri.clone()); + let page_path = self + .session + .as_ref() + .and_then(|session| session.page_path.clone()); + let prompt = format!( + r#"你是被 MNote Pi 调用的本机 Codex 自救代理。目标是在用户看到失败前,先尝试定位和修复 MNote/Pi 的疑难问题。 + +边界: +- 使用简体中文沟通和总结。 +- 遵守仓库 AGENTS.md;不要删除、回滚或覆盖用户已有改动。 +- 只做与本问题直接相关的最小修复;能验证就运行最相关验证。 +- 当前运行在 codex exec,sandbox={sandbox},approval=never;如果需要 sudo、系统级权限、外部凭证或超出 sandbox 的写入,不要强行绕过,明确说明阻塞。 +- 如果你完成了修复,最后说明修改文件、验证命令和结果。 +- 如果不能修复,最后给出明确原因、用户需要介入的动作,以及 Pi 下一步应如何降级汇报。 + +MNote Pi 会话: +- actorId: {actor_id} +- sessionId: {session_id} +- workspaceId: {workspace_id} +- rootUri: {root_uri} +- pagePath: {page_path} + +问题: +{issue} + +期望结果: +{desired} + +Pi 已尝试: +{attempted} + +证据/日志/上下文: +{evidence} +"#, + sandbox = sandbox, + actor_id = self.context.auth.actor_id, + session_id = self + .session + .as_ref() + .map(|session| session.session_id.as_str()) + .unwrap_or("standalone"), + workspace_id = self + .session + .as_ref() + .and_then(|session| session.workspace_id.as_deref()) + .unwrap_or(""), + root_uri = root_uri.as_deref().unwrap_or(""), + page_path = page_path.as_deref().unwrap_or(""), + issue = truncate_text(&issue, 12_000), + desired = truncate_text(&desired, 4_000), + attempted = truncate_text(&attempted, 8_000), + evidence = truncate_text(&evidence, 20_000), + ); + + let codex_bin = std::env::var("MNOTE_CODEX_BIN").unwrap_or_else(|_| "codex".into()); + let mut command = Command::new("timeout"); + command + .arg("--kill-after=10s") + .arg(format!("{timeout_secs}s")) + .arg(&codex_bin) + .arg("--ask-for-approval") + .arg("never") + .arg("exec") + .arg("--sandbox") + .arg(sandbox) + .arg("--cd") + .arg(&repo_root) + .arg("--output-last-message") + .arg(&final_message_path) + .arg("-"); + for extra_dir in pi_codex_extra_writable_dirs(&repo_root) { + if allow_writes { + command.arg("--add-dir").arg(extra_dir); + } + } + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("MNOTE_PI_CODEX_RESCUE", "1") + .env("NO_COLOR", "1"); + let mut child = command + .spawn() + .map_err(|error| WebError::internal(format!("启动 Codex 自救失败: {error}")))?; + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(prompt.as_bytes()).await.map_err(|error| { + WebError::internal(format!("写入 Codex 自救 prompt 失败: {error}")) + })?; + } + let output = child + .wait_with_output() + .await + .map_err(|error| WebError::internal(format!("等待 Codex 自救失败: {error}")))?; + let exit_code = output.status.code(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let final_message = fs::read_to_string(&final_message_path).unwrap_or_default(); + let timed_out = exit_code == Some(124) || exit_code == Some(137); + let ok = output.status.success() && !final_message.trim().is_empty(); + Ok(json!({ + "schema": "mnote.page_ai_pi.codex_rescue_result.v1", + "ok": ok, + "rescueId": rescue_id, + "status": if ok { "completed" } else if timed_out { "timed_out" } else { "failed" }, + "exitCode": exit_code, + "timedOut": timed_out, + "sandbox": sandbox, + "approvalPolicy": "never", + "repoRoot": repo_root, + "finalMessage": truncate_text(&final_message, 12_000), + "stdout": truncate_text(&stdout, 8_000), + "stderr": truncate_text(&stderr, 8_000), + "finalMessagePath": final_message_path, + "nextAction": if ok { + "Pi 应读取 finalMessage,总结 Codex 已修复内容、验证结果和仍需用户确认的事项。" + } else { + "Pi 应停止重复自救,向用户汇报 Codex 未能解决的原因、stdout/stderr 摘要和需要人工介入的动作。" + }, + })) + } } async fn execute_tool( @@ -1806,6 +4118,7 @@ async fn execute_tool( session_id: Option, tool_name: String, params: Value, + allow_bridge_approval: bool, ) -> Result, WebError> { let actor_id = ensure_authenticated(&state, &context)?; check_rate_limit(&actor_id, "tool", PI_LAB_MAX_TOOLS_PER_WINDOW)?; @@ -1814,29 +4127,77 @@ async fn execute_tool( } else { None }; + if let Some(session) = session.as_ref() { + if !pi_lab_session_allows_mnote_tool(session, &tool_name) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_tool_disabled", + format!("当前 AI 设置未启用 Pi 工具 {tool_name}"), + ) + .with_context(&context)); + } + } let facade = PiLabToolFacade { state, context: context.clone(), session: session.clone(), }; let started = now_ms(); - let result: Result = match tool_name.as_str() { - "mnote.current_page.read" => facade.current_page_read(params.clone()), - "mnote.selection.read" => facade.selection_read(params.clone()), - "mnote.allowed_roots.describe" => facade.allowed_roots_describe(), - "mnote.local_file.read" => facade.local_file_read(params.clone()), - "mnote.local_file.patch" => facade.local_file_patch(params.clone()), - "mnote.knowledge_rag.query" => facade.knowledge_rag_query(params.clone()).await, - "mnote.reference.open" => facade.reference_open(params.clone()).await, - "mnote.tool_receipt.write" => Ok(json!({ - "requestedReceipt": params, - "storage": "control_plane_turso_libsql_v1", - "note": "Pi Lab 由 execute_tool 统一写入 control-plane receipt journal", - })), - _ => Err(WebError::bad_request_code( - "page_ai_pi_lab_unknown_tool", - format!("未知 Pi Lab tool: {tool_name}"), - )), + let tool_policy = session + .as_ref() + .map(|session| pi_lab_session_tool_policy(session, &tool_name)) + .unwrap_or_else(|| "allow".into()); + if tool_policy == "deny" { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_tool_denied_by_permission_mode", + format!("当前 Pi 模式禁止调用工具 {tool_name}"), + ) + .with_context(&context)); + } + let approval_required = tool_policy == "ask"; + let approval_confirmed = !approval_required + || pi_lab_tool_approval_confirmed( + session.as_ref(), + ¶ms, + &tool_name, + allow_bridge_approval, + ); + let result: Result = if approval_required && !approval_confirmed { + Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_tool_approval_required", + format!("Pi 工具 {tool_name} 需要用户审批"), + )) + } else { + match tool_name.as_str() { + "mnote.current_page.read" => facade.current_page_read(params.clone()), + "mnote.selection.read" => facade.selection_read(params.clone()), + "mnote.allowed_roots.describe" => facade.allowed_roots_describe(), + "mnote.local_file.read" => facade.local_file_read(params.clone()), + "mnote.local_file.patch" => facade.local_file_patch(params.clone()), + "mnote.knowledge_rag.status" => facade.knowledge_rag_status(params.clone()).await, + "mnote.knowledge_rag.query" => facade.knowledge_rag_query(params.clone()).await, + "mnote.knowledge_rag.section_context" => { + facade.knowledge_rag_section_context(params.clone()).await + } + "mnote.knowledge_rag.open_reference" => facade.reference_open(params.clone()).await, + "mnote.reference.open" => facade.reference_open(params.clone()).await, + "mnote.codex_rescue.request" => facade.codex_rescue_request(params.clone()).await, + "mnote.tool_receipt.write" => Ok(json!({ + "requestedReceipt": params, + "diffSummary": params.get("diffSummary").cloned().unwrap_or(Value::Null), + "citations": params.get("citations").cloned().unwrap_or_else(|| json!([])), + "sources": params.get("sources").cloned().unwrap_or_else(|| json!([])), + "references": params.get("references").cloned().unwrap_or_else(|| json!([])), + "storage": "control_plane_turso_libsql_v1", + "note": "Pi Lab 由 execute_tool 统一写入 control-plane receipt journal", + })), + _ => Err(WebError::bad_request_code( + "page_ai_pi_lab_unknown_tool", + format!("未知 Pi Lab tool: {tool_name}"), + )), + } }; let normalized_file_path = params @@ -1922,6 +4283,9 @@ async fn execute_tool( "citationCount": citation_count, "receipt": receipt_payload, "elapsedMs": elapsed_ms, + "approvalRequired": approval_required, + "approvalConfirmed": approval_confirmed, + "toolPolicy": tool_policy, }), ); } @@ -1931,6 +4295,9 @@ async fn execute_tool( "result": payload, "receipt": receipt_payload, "elapsedMs": elapsed_ms, + "approvalRequired": approval_required, + "approvalConfirmed": approval_confirmed, + "toolPolicy": tool_policy, }))) } @@ -1984,6 +4351,7 @@ pub async fn status( sessions .values() .filter(|session| session.mnote_user_id == actor_id) + .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id)) .max_by_key(|session| session.updated_at_ms) .cloned() }); @@ -1993,6 +4361,7 @@ pub async fn status( sessions .values() .filter(|session| session.mnote_user_id == actor_id) + .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id)) .count() }) .unwrap_or(0); @@ -2002,6 +4371,7 @@ pub async fn status( sessions .values() .filter(|session| session.mnote_user_id == actor_id) + .filter(|session| !is_pi_lab_warmup_session_id(&session.session_id)) .map(|session| session.session_id.clone()) .collect::>() }) @@ -2015,6 +4385,11 @@ pub async fn status( .count() }) .unwrap_or(0); + let (runtime_impl, runtime_binary, runtime_available, runtime_install_hint) = + pi_runtime_status_snapshot(); + let runtime_error = current_session + .as_ref() + .and_then(|session| session.runtime_error.clone()); Ok(Json(json!({ "ok": true, "schema": PI_LAB_SCHEMA_STATUS, @@ -2023,9 +4398,37 @@ pub async fn status( "version": PI_LAB_VERSION, "provider": PI_LAB_PROVIDER, "runtimeMode": runtime_mode(), + "runtimeImplementation": runtime_impl, + "runtimeBinary": runtime_binary, + "runtimeAvailable": runtime_available, + "runtimeInstallHint": runtime_install_hint, + "runtimeError": runtime_error, "defaultModelProvider": default_model_provider(), "defaultModelId": default_model_id(), + "defaultThinkingLevel": default_thinking_level(), + "permissionMode": current_session.as_ref().and_then(|session| session_permission_mode(session)), "omnirouteBaseUrl": omniroute_base_url(), + "piExtensions": current_session + .as_ref() + .and_then(|session| session.runtime_policy_snapshot.as_ref()) + .and_then(|policy| policy.get("piExtensions").cloned()) + .unwrap_or_else(|| json!({})), + "enabledPiExtensions": current_session + .as_ref() + .map(pi_lab_enabled_extension_ids) + .unwrap_or_default(), + "piExtensionToolNames": current_session + .as_ref() + .map(pi_lab_runtime_pi_extension_tool_names) + .unwrap_or_default(), + "configuredPiExtensionSources": current_session + .as_ref() + .map(pi_lab_configured_extension_sources) + .unwrap_or_default(), + "piExtensionSources": current_session + .as_ref() + .map(pi_lab_runtime_extension_sources) + .unwrap_or_default(), "pid": current_session.as_ref().and_then(|session| session.runtime_pid), "sessionId": current_session.as_ref().map(|session| session.session_id.clone()), "providerSessionId": current_session.as_ref().map(|session| session.provider_session_id.clone()), @@ -2033,8 +4436,12 @@ pub async fn status( "activeSessionCount": active_session_count, "processCount": process_count, "managedPiSessionDirPolicy": "/.mnote/ai/pi-sessions//", - "disabledPiBuiltinTools": ["bash", "read", "write", "edit"], - "receiptStorage": "provider_neutral_jsonl_adapter_v1", + "managedPiBuiltinTools": current_session + .as_ref() + .map(pi_lab_enabled_builtin_tools) + .unwrap_or_default(), + "receiptStorage": "control_plane_turso_libsql_v1", + "receiptFallbackStorage": "provider_neutral_jsonl_debug_fallback_v1", "uiMode": "independent_mnote_native_drawer", }))) } @@ -2056,6 +4463,8 @@ pub async fn bootstrap( page_title: request.page_title, model_provider: request.model_provider, model_id: request.model_id, + thinking_level: request.thinking_level, + permission_mode: request.permission_mode, }; let requested = session_from_request(&state, &context, &start_request)?; let mut session = requested; @@ -2090,6 +4499,13 @@ pub async fn bootstrap( .to_string(), message: prompt.to_string(), streaming_behavior: None, + root_uri: start_request.root_uri.clone(), + workspace_id: start_request.workspace_id.clone(), + page_path: start_request.page_path.clone(), + page_title: start_request.page_title.clone(), + folder_path: None, + context_refs: None, + selected_context: None, }), ) .await? @@ -2113,14 +4529,26 @@ pub async fn start( let requested_session = session_from_request(&state, &context, &request)?; let mut requested_session = requested_session; requested_session.allowed_roots_snapshot = snapshot_allowed_roots(&state, &context)?; + if let Some(existing_session_id) = request.session_id.as_deref() { + if let Some(mut existing_session) = get_session(existing_session_id) { + ensure_session_owner(&state, &context, &existing_session)?; + if session_runtime_is_usable(&existing_session) + && session_runtime_config_matches(&existing_session, &requested_session) + { + refresh_runtime_session_context(&mut existing_session, &requested_session); + upsert_session(existing_session.clone()); + return Ok(Json(pi_lab_start_response(&existing_session, true))); + } + } + } + if kill_session_process(&requested_session.session_id).await { + update_session(&requested_session.session_id, |session| { + session.status = PiLabSessionStatus::Aborted; + session.runtime_pid = None; + }); + } let session = start_runtime_for_session(&state, requested_session).await?; - Ok(Json(json!({ - "ok": true, - "schema": "mnote.page_ai_pi.start.v1", - "session": session, - "disabledPiBuiltinTools": ["bash", "read", "write", "edit"], - "mnoteToolOnly": true, - }))) + Ok(Json(pi_lab_start_response(&session, false))) } pub async fn send( @@ -2140,18 +4568,80 @@ pub async fn send( )); } update_session(&request.session_id, |session| { + if request + .root_uri + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + session.root_uri = request.root_uri.clone(); + } + if request + .workspace_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + session.workspace_id = request.workspace_id.clone(); + } + if request + .page_path + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + session.page_path = request.page_path.clone(); + } + if request + .page_title + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + session.page_title = request.page_title.clone(); + } session.status = PiLabSessionStatus::TurnRunning; session.message_count += 1; }); - let command = json!({ + let command_session = get_session(&request.session_id).unwrap_or(session.clone()); + write_pi_mnote_context_snapshot( + &command_session, + request.selected_context.as_ref(), + request.context_refs.as_deref(), + )?; + let input_context_prefix = pi_mnote_input_context_prefix( + &command_session, + request.selected_context.as_ref(), + request.context_refs.as_deref(), + )?; + let command_message = pi_lab_command_message_for_session( + &command_session, + &request.message, + &input_context_prefix, + ); + let plan_mode_prompt_applied = session_permission_mode(&command_session) == Some("plan"); + let mut command = json!({ "id": generate_id("pi_rpc"), "type": "prompt", - "message": request.message, - "streamingBehavior": request.streaming_behavior, + "message": command_message, + "displayMessage": request.message, + "context": { + "rootUri": request.root_uri, + "workspaceId": request.workspace_id, + "pagePath": request.page_path, + "pageTitle": request.page_title, + "folderPath": request.folder_path, + "contextRefs": request.context_refs, + "selectedContext": request.selected_context, + }, }); - if session.runtime_mode == "mock" { + if let Some(streaming_behavior) = request + .streaming_behavior + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + command["streamingBehavior"] = json!(streaming_behavior); + } + if command_session.runtime_mode == "mock" { publish_event( - &session.session_id, + &command_session.session_id, "pi_rpc_event", json!({ "type": "message_update", @@ -2162,7 +4652,7 @@ pub async fn send( }), ); publish_event( - &session.session_id, + &command_session.session_id, "pi_rpc_event", json!({ "type": "citation", @@ -2172,11 +4662,11 @@ pub async fn send( }), ); publish_event( - &session.session_id, + &command_session.session_id, "pi_rpc_event", json!({ "type": "diff", - "files": [session.page_path.clone().unwrap_or_else(|| "page.md".into())], + "files": [command_session.page_path.clone().unwrap_or_else(|| "page.md".into())], }), ); update_session(&request.session_id, |session| { @@ -2191,7 +4681,7 @@ pub async fn send( return Err(error); } } - let current = get_session(&request.session_id).unwrap_or(session.clone()); + let current = get_session(&request.session_id).unwrap_or(command_session.clone()); persist_upsert_run(&state, ¤t)?; persist_append_event( &state, @@ -2204,9 +4694,11 @@ pub async fn send( "schema": "mnote.page_ai_pi.send.v1", "sessionId": request.session_id, - "providerSessionId": session.provider_session_id, + "providerSessionId": current.provider_session_id, "accepted": true, "eventStream": "/api/page-ai/pi/events", + "permissionMode": session_permission_mode(¤t), + "planModePromptApplied": plan_mode_prompt_applied, }))) } @@ -2221,21 +4713,24 @@ pub async fn abort( let session = get_session_for_context(&state, &context, &request.session_id)?; if session.runtime_mode != "mock" { let _ = send_rpc_command(&request.session_id, json!({"type": "abort"})).await; - let handle = PI_LAB_PROCESSES - .lock() - .ok() - .and_then(|processes| processes.get(&request.session_id).cloned()); - if let Some(handle) = handle { - let _ = handle.child.lock().await.kill().await; - if let Ok(mut processes) = PI_LAB_PROCESSES.lock() { - processes.remove(&request.session_id); - } - } + let _ = kill_session_process(&request.session_id).await; } update_session(&request.session_id, |session| { session.status = PiLabSessionStatus::Aborted; }); - publish_event(&request.session_id, "runtime_aborted", json!({})); + let abort_payload = json!({ + "schema": "mnote.page_ai_pi.abort.v1", + "sessionId": request.session_id, + "providerSessionId": session.provider_session_id, + "aborted": true, + "stopReason": "aborted", + "command": "abort", + }); + publish_event( + &request.session_id, + "runtime_aborted", + abort_payload.clone(), + ); // 持久化 abort 状态到 DB if let Some(current) = get_session(&request.session_id) { if let Err(e) = persist_upsert_run(&state, ¤t) { @@ -2257,6 +4752,177 @@ pub async fn abort( "sessionId": request.session_id, "providerSessionId": session.provider_session_id, "aborted": true, + "stopReason": "aborted", + "command": "abort", + }))) +} + +pub async fn ui_response( + State(state): State, + Extension(context): Extension, + Json(request): Json, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + ensure_authenticated(&state, &context)?; + let session = get_session_for_context(&state, &context, &request.session_id)?; + if request.id.trim().is_empty() { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_ui_request_id_required", + "extension UI request id 不能为空", + )); + } + let mut command = json!({ + "type": "extension_ui_response", + "id": request.id, + }); + if let Some(value) = request.value.clone() { + command["value"] = value; + } + if let Some(confirmed) = request.confirmed { + command["confirmed"] = json!(confirmed); + } + if request.cancelled.unwrap_or(false) { + command["cancelled"] = json!(true); + } + if let Some(approval) = request.mnote_approval.clone() { + command["mnoteApproval"] = approval; + } + if session.runtime_mode != "mock" { + send_rpc_command(&request.session_id, command.clone()).await?; + } + if request.confirmed == Some(true) && !request.cancelled.unwrap_or(false) { + confirm_pending_approval(&request.session_id, request.mnote_approval.as_ref()); + } else if request.cancelled.unwrap_or(false) || request.confirmed == Some(false) { + cancel_pending_approval(&request.session_id, request.mnote_approval.as_ref()); + } + store_pending_ui_response( + &request.session_id, + &request.id, + request.value.clone(), + request.confirmed, + request.cancelled.unwrap_or(false), + ); + persist_append_event( + &state, + &session, + "extension_ui_response", + &json!({ + "id": command.get("id").cloned().unwrap_or(Value::Null), + "method": request.method, + "value": command.get("value").cloned().unwrap_or(Value::Null), + "confirmed": command.get("confirmed").cloned().unwrap_or(Value::Null), + "cancelled": command.get("cancelled").cloned().unwrap_or(Value::Null), + "mnoteApproval": command.get("mnoteApproval").cloned().unwrap_or(Value::Null), + }), + )?; + Ok(Json(json!({ + "ok": true, + "schema": "mnote.page_ai_pi.ui_response.v1", + "sessionId": request.session_id, + "providerSessionId": session.provider_session_id, + "requestId": command.get("id").cloned().unwrap_or(Value::Null), + }))) +} + +pub async fn ui_request_bridge( + State(state): State, + Extension(context): Extension, + headers: HeaderMap, + Json(request): Json, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + let session = get_session_for_bridge(&headers, &request.session_id)?; + let context = context_for_session(context, &session); + ensure_authenticated(&state, &context)?; + if request.id.trim().is_empty() { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_ui_request_id_required", + "extension UI request id 不能为空", + )); + } + let mut payload = json!({ + "type": "extension_ui_request", + "id": request.id.clone(), + "method": request.method.clone(), + "title": request.title.clone(), + "message": request.message.clone(), + }); + for (key, value) in request.extra.iter() { + if !matches!( + key.as_str(), + "sessionId" + | "session_id" + | "id" + | "method" + | "title" + | "message" + | "timeoutMs" + | "timeout_ms" + | "mnoteApproval" + | "mnote_approval" + ) { + payload[key] = value.clone(); + } + } + if let Some(approval) = request.mnote_approval.clone() { + payload["mnoteApproval"] = approval; + } + record_pending_approval_from_event(&session.session_id, &payload); + persist_append_event(&state, &session, "pi_rpc_event", &payload)?; + publish_event(&session.session_id, "pi_rpc_event", payload); + + let approval_id = request + .mnote_approval + .as_ref() + .and_then(Value::as_object) + .and_then(|approval| { + approval + .get("approvalId") + .or_else(|| approval.get("approval_id")) + .and_then(Value::as_str) + }) + .map(str::to_string) + .unwrap_or_else(|| request.id.clone()); + let timeout_ms = request.timeout_ms.unwrap_or(60_000).clamp(1_000, 120_000); + let started = now_ms(); + while now_ms().saturating_sub(started) < u128::from(timeout_ms) { + if let Some(response) = take_pending_ui_response(&session.session_id, &request.id) { + return Ok(Json(json!({ + "ok": true, + "confirmed": response.confirmed.unwrap_or(false), + "cancelled": response.cancelled, + "value": response.value.unwrap_or(Value::Null), + "requestId": request.id, + }))); + } + if let Some((confirmed, cancelled)) = + pending_approval_response(&session.session_id, &approval_id) + { + if confirmed { + return Ok(Json(json!({ + "ok": true, + "confirmed": true, + "requestId": request.id, + }))); + } + if cancelled { + return Ok(Json(json!({ + "ok": true, + "cancelled": true, + "requestId": request.id, + }))); + } + } + tokio::time::sleep(Duration::from_millis(120)).await; + } + Ok(Json(json!({ + "ok": false, + "cancelled": true, + "code": "page_ai_pi_lab_ui_request_timeout", + "message": "等待 MNote 审批超时", + "requestId": request.id, }))) } @@ -2267,13 +4933,41 @@ pub async fn events( ) -> Result>>, WebError> { ensure_enabled(&state)?; cleanup_expired_sessions(); - ensure_authenticated(&state, &context)?; + let user_id = ensure_authenticated(&state, &context)?; let session_filter = if let Some(session_id) = query.session_id { let session = get_session_for_context(&state, &context, &session_id)?; Some(session.session_id) } else { None }; + let backlog = if let Some(session_id) = session_filter.as_deref() { + let run_id = pi_run_id(session_id); + let limit = query.limit.unwrap_or(200).min(1000); + state + .control_plane() + .list_ai_runtime_events(&user_id, &run_id, limit) + .map_err(|e| WebError::internal(format!("查询 Pi Lab SSE backlog 失败: {e}")))? + .into_iter() + .map(|event| { + let payload: Value = serde_json::from_str(&event.payload_json).unwrap_or(json!({})); + let event_name = event.event_type.clone(); + let data = json!({ + "schema": PI_LAB_SCHEMA_EVENT, + "sessionId": session_id, + "kind": event_name.clone(), + "createdAt": event.created_at, + "payload": payload, + "replayed": true, + }); + Ok(SseEvent::default() + .event(event_name) + .id(event.id) + .data(data.to_string())) + }) + .collect::>>() + } else { + Vec::new() + }; let rx = PI_LAB_EVENT_TX.subscribe(); let hello = stream::once(async { Ok(SseEvent::default().event("connected").data( @@ -2285,6 +4979,7 @@ pub async fn events( .to_string(), )) }); + let replay = stream::iter(backlog); let stream = BroadcastStream::new(rx).filter_map(move |event| { let session_filter = session_filter.clone(); async move { @@ -2305,7 +5000,7 @@ pub async fn events( .data(value.to_string()))) } }); - Ok(Sse::new(hello.chain(stream)).keep_alive( + Ok(Sse::new(hello.chain(replay).chain(stream)).keep_alive( KeepAlive::new() .interval(Duration::from_secs(30)) .text("keepalive"), @@ -2325,6 +5020,7 @@ pub async fn tool_call( request.session_id, request.tool_name, request.params, + false, ) .await } @@ -2351,10 +5047,185 @@ pub async fn tool_call_bridge( Some(session_id), request.tool_name, request.params, + true, ) .await } +async fn execute_mcp_bridge_request( + session: &PiLabSession, + request: &PiLabMcpBridgeRequest, +) -> Result { + if !pi_lab_mcp_enabled(session) { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_mcp_disabled", + "当前 Pi session 未启用 MCP bridge", + )); + } + let server = request.server.trim(); + if server.is_empty() { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_mcp_server_required", + "MCP bridge 缺少 server", + )); + } + let mode = request.mode.trim().to_ascii_lowercase(); + if !matches!(mode.as_str(), "list" | "status" | "call") { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_mcp_mode_invalid", + "MCP bridge mode 只支持 list/status/call", + )); + } + let tool = request + .tool + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if mode == "call" && tool.is_none() { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_mcp_tool_required", + "MCP bridge mode=call 时必须提供 tool", + )); + } + if request + .arguments + .as_ref() + .is_some_and(|value| !value.is_object()) + { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_mcp_arguments_invalid", + "MCP bridge arguments 必须是对象", + )); + } + + let server_config = session + .runtime_policy_snapshot + .as_ref() + .and_then(|value| value.get("mcpServers")) + .and_then(Value::as_object) + .and_then(|servers| servers.get(server)) + .ok_or_else(|| { + WebError::bad_request_code( + "page_ai_pi_lab_mcp_server_not_configured", + format!("MCP server 未配置: {server}"), + ) + })?; + if server_config + .get("disabled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Err(WebError::new( + StatusCode::FORBIDDEN, + "page_ai_pi_lab_mcp_server_disabled", + format!("MCP server 已禁用: {server}"), + )); + } + + let config_path = PathBuf::from(&session.pi_session_dir) + .join("config") + .join("mcp.json"); + if !config_path.is_file() { + return Err(WebError::internal(format!( + "Pi session MCP config 不存在: {}", + config_path.display() + ))); + } + let client_path = mnote_pi_mcp_client_path(); + if !client_path.is_file() { + return Err(WebError::internal(format!( + "MNote MCP client 不存在: {}", + client_path.display() + ))); + } + + let payload = serde_json::to_vec(&json!({ + "server": server, + "mode": mode, + "tool": tool, + "arguments": request.arguments.clone().unwrap_or_else(|| json!({})), + })) + .map_err(|error| WebError::internal(format!("序列化 MCP bridge 请求失败: {error}")))?; + let node_binary = env_trimmed("MNOTE_PAGE_AI_PI_MCP_NODE_BIN").unwrap_or_else(|| "node".into()); + let mut command = Command::new(node_binary); + command + .arg(&client_path) + .arg("-") + .env("MNOTE_MCP_CONFIG_PATH", &config_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(parent) = client_path.parent() { + command.current_dir(parent); + } + let mut child = command + .spawn() + .map_err(|error| WebError::internal(format!("启动 MNote MCP client 失败: {error}")))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| WebError::internal("MNote MCP client stdin 不可用".to_string()))?; + stdin + .write_all(&payload) + .await + .map_err(|error| WebError::internal(format!("写入 MCP bridge 请求失败: {error}")))?; + stdin + .shutdown() + .await + .map_err(|error| WebError::internal(format!("关闭 MCP bridge stdin 失败: {error}")))?; + drop(stdin); + + let output = tokio::time::timeout( + Duration::from_millis(PI_LAB_MCP_BRIDGE_TIMEOUT_MS), + child.wait_with_output(), + ) + .await + .map_err(|_| { + WebError::new( + StatusCode::GATEWAY_TIMEOUT, + "page_ai_pi_lab_mcp_timeout", + format!("MCP bridge 超时: {server}/{mode}"), + ) + })? + .map_err(|error| WebError::internal(format!("等待 MNote MCP client 失败: {error}")))?; + if output.stdout.len() > PI_LAB_MCP_BRIDGE_MAX_OUTPUT_BYTES { + return Err(WebError::new( + StatusCode::BAD_GATEWAY, + "page_ai_pi_lab_mcp_output_too_large", + "MCP bridge 输出超过 2 MiB 限制", + )); + } + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WebError::new( + StatusCode::BAD_GATEWAY, + "page_ai_pi_lab_mcp_client_failed", + format!( + "MNote MCP client 执行失败: {}", + stderr.trim().chars().take(2000).collect::() + ), + )); + } + let stdout = String::from_utf8(output.stdout) + .map_err(|error| WebError::internal(format!("MCP bridge 输出不是 UTF-8: {error}")))?; + serde_json::from_str(stdout.trim()) + .map_err(|error| WebError::internal(format!("解析 MCP bridge 输出失败: {error}"))) +} + +pub async fn mcp_call_bridge( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, WebError> { + ensure_enabled(&state)?; + cleanup_expired_sessions(); + let session = get_session_for_bridge(&headers, &request.session_id)?; + check_rate_limit(&session.mnote_user_id, "mcp", PI_LAB_MAX_TOOLS_PER_WINDOW)?; + Ok(Json(execute_mcp_bridge_request(&session, &request).await?)) +} + fn snapshot_allowed_roots( state: &AppState, context: &RequestContext, @@ -2398,12 +5269,14 @@ fn build_run_runtime_json(session: &PiLabSession) -> String { "runtimeMode": session.runtime_mode, "modelProvider": session.model_provider, "modelId": session.model_id, + "thinkingLevel": session.thinking_level, "messageCount": session.message_count, "pagePath": session.page_path, "pageTitle": session.page_title, "rootUri": session.root_uri, "workspaceId": session.workspace_id, "allowedRootsSnapshot": session.allowed_roots_snapshot, + "runtimePolicy": session.runtime_policy_snapshot, })) .unwrap_or_default() } @@ -2492,6 +5365,7 @@ pub async fn list_sessions( let pi_runs: Vec = runs .into_iter() .filter(|r| r.profile == PI_LAB_PROFILE && r.acp_runtime == PI_LAB_ACP_RUNTIME) + .filter(|r| !is_pi_lab_warmup_session_id(&r.session_id)) .map(|r| { let runtime: Value = serde_json::from_str(&r.runtime_json).unwrap_or(json!({})); let preview = runtime @@ -2655,16 +5529,178 @@ pub async fn get_session_events( }))) } +pub async fn delete_session_history( + State(state): State, + Extension(context): Extension, + axum::extract::Path(path): axum::extract::Path, + Query(query): Query, +) -> Result, WebError> { + if !enabled(&state) { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_disabled", + "Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭", + )); + } + let user_id = ensure_authenticated(&state, &context)?; + let run_id = pi_run_id(&path.session_id); + let run = state + .control_plane() + .find_ai_runtime_run(&user_id, &run_id) + .map_err(|e| WebError::internal(format!("查询 Pi Lab session 失败: {e}")))? + .ok_or_else(|| { + WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_session_not_found", + "Pi Lab session 不存在或不属于当前用户", + ) + })?; + + if run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_session_not_found", + "Pi Lab session 不存在或不属于当前用户", + )); + } + + let deleted = state + .control_plane() + .delete_ai_runtime_session(&user_id, &path.session_id, query.workspace_id.as_deref()) + .map_err(|e| WebError::internal(format!("删除 Pi Lab session 失败: {e}")))?; + let killed = kill_session_process(&path.session_id).await; + let removed = remove_session(&path.session_id); + + Ok(Json(json!({ + "ok": true, + "schema": "mnote.page_ai_pi.delete_session.v1", + "sessionId": path.session_id, + "deletedRuns": deleted, + "runtimeKilled": killed, + "memorySessionRemoved": removed, + }))) +} + +pub async fn clear_sessions( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result, WebError> { + if !enabled(&state) { + return Ok(Json(json!({ + "ok": true, + "schema": "mnote.page_ai_pi.clear_sessions.v1", + "deletedRuns": 0, + "sessionIds": [], + }))); + } + let user_id = ensure_authenticated(&state, &context)?; + let limit = query.limit.unwrap_or(500).min(1000); + let runs = state + .control_plane() + .list_ai_runtime_runs(&user_id, query.workspace_id.as_deref(), None, None, limit) + .map_err(|e| WebError::internal(format!("查询 Pi Lab session 历史失败: {e}")))?; + + let mut seen = HashSet::new(); + let mut session_ids = Vec::new(); + for run in runs { + if run.profile == PI_LAB_PROFILE + && run.acp_runtime == PI_LAB_ACP_RUNTIME + && seen.insert(run.session_id.clone()) + { + session_ids.push(run.session_id); + } + } + + let mut deleted_runs = 0usize; + let mut killed_sessions = 0usize; + let mut memory_sessions_removed = 0usize; + for session_id in &session_ids { + deleted_runs += state + .control_plane() + .delete_ai_runtime_session(&user_id, session_id, query.workspace_id.as_deref()) + .map_err(|e| WebError::internal(format!("清空 Pi Lab session 失败: {e}")))?; + if kill_session_process(session_id).await { + killed_sessions += 1; + } + if remove_session(session_id) { + memory_sessions_removed += 1; + } + } + + Ok(Json(json!({ + "ok": true, + "schema": "mnote.page_ai_pi.clear_sessions.v1", + "deletedRuns": deleted_runs, + "sessionIds": session_ids, + "killedSessions": killed_sessions, + "memorySessionsRemoved": memory_sessions_removed, + }))) +} + +pub async fn rename_session( + State(state): State, + Extension(context): Extension, + axum::extract::Path(path): axum::extract::Path, + Json(request): Json, +) -> Result, WebError> { + if !enabled(&state) { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_disabled", + "Pi Lab 已被 MNOTE_PAGE_AI_PI_LAB=0 强制关闭", + )); + } + let user_id = ensure_authenticated(&state, &context)?; + let title = request.title.trim(); + if title.is_empty() { + return Err(WebError::bad_request_code( + "page_ai_pi_lab_session_title_required", + "session 标题不能为空", + )); + } + let renamed = state + .control_plane() + .rename_ai_runtime_session( + &user_id, + &path.session_id, + request.workspace_id.as_deref(), + title, + ) + .map_err(|e| WebError::internal(format!("重命名 Pi Lab session 失败: {e}")))?; + if renamed + .iter() + .all(|run| run.profile != PI_LAB_PROFILE || run.acp_runtime != PI_LAB_ACP_RUNTIME) + { + return Err(WebError::new( + StatusCode::NOT_FOUND, + "page_ai_pi_lab_session_not_found", + "Pi Lab session 不存在或不属于当前用户", + )); + } + update_session(&path.session_id, |session| { + session.page_title = Some(title.to_string()); + }); + Ok(Json(json!({ + "ok": true, + "schema": "mnote.page_ai_pi.rename_session.v1", + "sessionId": path.session_id, + "title": title, + "updatedRuns": renamed.len(), + }))) +} + #[cfg(test)] mod tests { use super::*; use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; + use control_plane::{DirectoryGrantInput, UpsertAiPolicyInput, UpsertUserInput}; use tower::util::ServiceExt; - fn test_app() -> axum::Router { - build_app(AppState::new(AppConfig { + fn test_state() -> AppState { + AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "test".into(), bind_addr: "127.0.0.1:0".into(), @@ -2684,7 +5720,322 @@ mod tests { dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), - })) + }) + } + + fn test_app() -> axum::Router { + build_app(test_state()) + } + + fn temp_root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("{name}-{}", now_ms())); + fs::create_dir_all(&root).expect("temp root"); + root.canonicalize().expect("canonical temp root") + } + + fn file_uri(path: &Path) -> String { + format!("file://{}", path.to_string_lossy()) + } + + fn grant_directory(state: &AppState, user_id: &str, root: &Path, permission: &str) -> String { + let root_uri = file_uri(root); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some(user_id.into()), + email: Some(format!("{user_id}@example.com")), + username: user_id.into(), + display_name: user_id.into(), + role: None, + password_hash: None, + }) + .expect("upsert user"); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("test_admin".into()), + email: Some("test-admin@example.com".into()), + username: "test_admin".into(), + display_name: "test_admin".into(), + role: Some("admin".into()), + password_hash: None, + }) + .expect("upsert admin"); + state + .control_plane() + .grant_directory_access(DirectoryGrantInput { + user_id: user_id.into(), + workspace_id: None, + root_uri: root_uri.clone(), + root_path: root.to_string_lossy().to_string(), + permission: permission.into(), + recursive: true, + capabilities: vec!["ai".into()], + source: "test".into(), + created_by: Some("test_admin".into()), + }) + .expect("grant directory"); + root_uri + } + + fn upsert_ai_policy(state: &AppState, user_id: &str, model_policy: Value) { + state + .control_plane() + .upsert_ai_policy(UpsertAiPolicyInput { + id: None, + user_id: Some(user_id.into()), + workspace_id: None, + allowed_roots_json: "[]".into(), + model_policy_json: model_policy.to_string(), + quota_json: "{}".into(), + }) + .expect("upsert ai policy"); + } + + async fn request_json( + app: axum::Router, + uri: &str, + actor_id: &str, + body: Value, + ) -> (StatusCode, Value) { + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .header("x-mnote-actor-id", actor_id) + .header("x-mnote-actor-type", "user") + .body(Body::from(body.to_string())) + .expect("request"), + ) + .await + .expect("response"); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + (status, payload) + } + + async fn bridge_request_json( + app: axum::Router, + actor_id: &str, + bridge_token: &str, + body: Value, + ) -> (StatusCode, Value) { + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/page-ai/pi/tool-call-bridge") + .header("content-type", "application/json") + .header("x-mnote-actor-id", actor_id) + .header("x-mnote-actor-type", "user") + .header(HEADER_PI_LAB_BRIDGE_TOKEN, bridge_token) + .body(Body::from(body.to_string())) + .expect("request"), + ) + .await + .expect("response"); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + (status, payload) + } + + fn confirm_test_approval(session_id: &str, approval: &Value) { + record_pending_approval_from_event( + session_id, + &json!({ + "type": "extension_ui_request", + "method": "confirm", + "mnoteApproval": approval, + }), + ); + confirm_pending_approval(session_id, Some(approval)); + } + + fn permission_mode_test_session(root: &Path, mode: &str) -> PiLabSession { + PiLabSession { + session_id: format!("pi_lab_permission_{mode}"), + mnote_user_id: "pi_permission_user".into(), + bridge_token: "bridge".into(), + status: PiLabSessionStatus::RuntimeRunning, + provider_session_id: format!("prov_permission_{mode}"), + pi_session_dir: root + .join(format!("session-{mode}")) + .to_string_lossy() + .to_string(), + pi_session_file: None, + root_uri: Some(file_uri(root)), + workspace_id: None, + page_path: Some("note.md".into()), + page_title: Some("Permission mode test".into()), + model_provider: Some("omniroute".into()), + model_id: Some("pi-fast".into()), + thinking_level: Some("medium".into()), + allowed_roots_snapshot: Some(json!({ + "roots": [{ + "rootUri": file_uri(root), + "rootPath": root.to_string_lossy(), + "permission": "write" + }] + })), + runtime_policy_snapshot: Some(json!({ + "permissionMode": mode, + "allowExternalPiExtensions": true, + "enabledPiExtensions": ["pi-permission-system"], + "enabledPiExtensionSources": ["npm:@gotgenes/pi-permission-system"], + "mnoteToolNames": [ + "mnote.current_page.read", + "mnote.selection.read", + "mnote.allowed_roots.describe", + "mnote.local_file.read", + "mnote.local_file.patch", + "mnote.knowledge_rag.status", + "mnote.knowledge_rag.query", + "mnote.knowledge_rag.section_context", + "mnote.knowledge_rag.open_reference", + "mnote.reference.open", + "mnote.codex_rescue.request", + "mnote.tool_receipt.write" + ], + "mnoteToolPolicies": { + "mnote.current_page.read": "allow", + "mnote.selection.read": "allow", + "mnote.allowed_roots.describe": "allow", + "mnote.local_file.read": "ask", + "mnote.local_file.patch": "ask", + "mnote.knowledge_rag.status": "allow", + "mnote.knowledge_rag.query": "allow", + "mnote.knowledge_rag.section_context": "allow", + "mnote.knowledge_rag.open_reference": "allow", + "mnote.reference.open": "allow", + "mnote.codex_rescue.request": "ask", + "mnote.tool_receipt.write": "allow" + } + })), + runtime_pid: None, + runtime_mode: "mock".into(), + runtime_error: None, + created_at_ms: 1000, + updated_at_ms: 2000, + message_count: 0, + } + } + + fn permission_config_for_mode(root: &Path, mode: &str) -> Value { + let session = permission_mode_test_session(root, mode); + let config_dir = root.join(format!("config-{mode}")); + let path = ensure_session_pi_permission_config(&session, &config_dir) + .expect("permission config") + .expect("permission-system enabled"); + serde_json::from_slice(&fs::read(path).expect("permission config file")).unwrap() + } + + #[test] + fn permission_modes_compile_distinct_runtime_policies() { + let root = temp_root("mnote-pi-permission-modes"); + let root_rule = shell_glob_escape_path(root.to_string_lossy().trim_end_matches('/')); + let root_wildcard_rule = format!("{root_rule}/*"); + + let confirm = permission_config_for_mode(&root, "confirm"); + assert_eq!(confirm["permission"]["path"]["*"], "ask"); + assert_eq!(confirm["permission"]["path"][&root_rule], "ask"); + assert_eq!(confirm["permission"]["read"], "allow"); + assert_eq!(confirm["permission"]["write"], "ask"); + assert_eq!(confirm["permission"]["edit"], "ask"); + assert_eq!(confirm["permission"]["bash"]["*"], "ask"); + assert_eq!(confirm["permission"]["bash"]["rm -rf *"], "ask"); + assert_eq!(confirm["permission"]["mnote_local_file_read"], "ask"); + assert_eq!(confirm["permission"]["mnote_local_file_patch"], "ask"); + + let auto_edit = permission_config_for_mode(&root, "auto_edit"); + assert_eq!(auto_edit["permission"]["path"]["*"], "ask"); + assert_eq!(auto_edit["permission"]["path"][&root_rule], "allow"); + assert_eq!( + auto_edit["permission"]["path"][&root_wildcard_rule], + "allow" + ); + assert_eq!(auto_edit["permission"]["read"], "allow"); + assert_eq!(auto_edit["permission"]["write"], "allow"); + assert_eq!(auto_edit["permission"]["edit"], "allow"); + assert_eq!(auto_edit["permission"]["bash"]["*"], "ask"); + assert_eq!(auto_edit["permission"]["bash"]["rm -rf *"], "ask"); + assert_eq!(auto_edit["permission"]["mnote_local_file_read"], "allow"); + assert_eq!(auto_edit["permission"]["mnote_local_file_patch"], "allow"); + + let plan = permission_config_for_mode(&root, "plan"); + assert_eq!(plan["permission"]["path"]["*"], "deny"); + assert_eq!(plan["permission"]["path"][&root_rule], "allow"); + assert_eq!(plan["permission"]["path"][&root_wildcard_rule], "allow"); + assert_eq!(plan["permission"]["read"], "allow"); + assert_eq!(plan["permission"]["write"], "deny"); + assert_eq!(plan["permission"]["edit"], "deny"); + assert_eq!(plan["permission"]["bash"]["*"], "deny"); + assert_eq!(plan["permission"]["bash"]["rm -rf *"], "deny"); + assert_eq!(plan["permission"]["mnote_local_file_read"], "allow"); + assert_eq!(plan["permission"]["mnote_local_file_patch"], "deny"); + assert_eq!(plan["permission"]["mnote_codex_rescue_request"], "deny"); + + let full_access = permission_config_for_mode(&root, "full_access"); + assert_eq!(full_access["permission"]["path"]["*"], "allow"); + assert_eq!(full_access["permission"]["path"][&root_rule], "allow"); + assert_eq!(full_access["permission"]["read"], "allow"); + assert_eq!(full_access["permission"]["write"], "allow"); + assert_eq!(full_access["permission"]["edit"], "allow"); + assert_eq!(full_access["permission"]["bash"]["*"], "allow"); + assert_eq!(full_access["permission"]["bash"]["rm -rf *"], "allow"); + assert_eq!(full_access["permission"]["mnote_local_file_read"], "allow"); + assert_eq!(full_access["permission"]["mnote_local_file_patch"], "allow"); + assert_eq!( + full_access["permission"]["mnote_codex_rescue_request"], + "ask" + ); + } + + #[test] + fn plan_mode_wraps_prompt_with_readonly_instructions() { + let root = temp_root("mnote-pi-plan-mode-prompt"); + let plan_session = permission_mode_test_session(&root, "plan"); + let prompt = + pi_lab_effective_prompt_for_session(&plan_session, "请删除这个目录并修改 note.md"); + assert!(prompt.contains("当前是 MNote Pi 计划模式")); + assert!(prompt.contains("不要实际写入、删除、移动、重命名文件")); + assert!(prompt.contains("不要执行 bash/write/edit/rm/mv/cp/mkdir")); + assert!(prompt.contains("用户原始请求")); + assert!(prompt.contains("请删除这个目录并修改 note.md")); + + let full_access_session = permission_mode_test_session(&root, "full_access"); + assert_eq!( + pi_lab_effective_prompt_for_session(&full_access_session, "请删除这个目录"), + "请删除这个目录" + ); + } + + #[test] + fn skill_prompt_keeps_slash_command_first_with_dynamic_context() { + let root = temp_root("mnote-pi-skill-prompt-context"); + let full_access_session = permission_mode_test_session(&root, "full_access"); + let context = "[[MNOTE_PI_CONTEXT_V1:7b7d]]\n"; + let prompt = pi_lab_command_message_for_session( + &full_access_session, + "/skill:vpn 请返回 skill 中定义的端口", + context, + ); + assert!(prompt.starts_with("/skill:vpn\n[[MNOTE_PI_CONTEXT_V1:")); + assert!(prompt.ends_with("请返回 skill 中定义的端口")); + + let plan_session = permission_mode_test_session(&root, "plan"); + let plan_prompt = + pi_lab_command_message_for_session(&plan_session, "/skill:vpn 检查代理配置", context); + assert!(plan_prompt.starts_with("/skill:vpn\n[[MNOTE_PI_CONTEXT_V1:")); + assert!(plan_prompt.contains("当前是 MNote Pi 计划模式")); + assert!(plan_prompt.contains("用户原始请求:\n检查代理配置")); } #[test] @@ -2730,7 +6081,9 @@ mod tests { page_title: Some("Test Page".into()), model_provider: Some("omniroute".into()), model_id: Some("freefirst".into()), + thinking_level: Some("medium".into()), allowed_roots_snapshot: None, + runtime_policy_snapshot: None, runtime_pid: Some(12345), runtime_mode: "mock".into(), runtime_error: None, @@ -2769,7 +6122,9 @@ mod tests { page_title: None, model_provider: None, model_id: None, + thinking_level: Some("medium".into()), allowed_roots_snapshot: None, + runtime_policy_snapshot: None, runtime_pid: None, runtime_mode: "mock".into(), runtime_error: None, @@ -2791,6 +6146,104 @@ mod tests { assert_eq!(parsed["delta"], "hello"); } + #[test] + fn tool_bridge_extension_does_not_duplicate_native_skill_loading() { + let root = temp_root("mnote-pi-bridge-no-skill-dup"); + let skill_path = root.join("context7").join("SKILL.md"); + fs::create_dir_all(skill_path.parent().unwrap()).expect("skill dir"); + fs::write( + &skill_path, + "---\nname: context7\n---\n\n# Context7\n\nUse Context7 docs.\n", + ) + .expect("skill file"); + let session = PiLabSession { + session_id: "pi_lab_skill_no_dup".into(), + mnote_user_id: "user_test".into(), + bridge_token: "bridge".into(), + status: PiLabSessionStatus::RuntimeRunning, + provider_session_id: "prov_skill".into(), + pi_session_dir: root.join("session").to_string_lossy().to_string(), + pi_session_file: None, + root_uri: None, + workspace_id: None, + page_path: None, + page_title: None, + model_provider: None, + model_id: None, + thinking_level: Some("medium".into()), + allowed_roots_snapshot: None, + runtime_policy_snapshot: Some(json!({ + "enabledSkillSources": [skill_path.to_string_lossy()] + })), + runtime_pid: None, + runtime_mode: "mock".into(), + runtime_error: None, + created_at_ms: 1000, + updated_at_ms: 2000, + message_count: 0, + }; + + let manifest = mnote_pi_tool_manifest(&session); + assert!(manifest + .as_array() + .unwrap() + .iter() + .any(|tool| tool["piName"] == "mnote_current_page_read")); + let extension_path = mnote_pi_extension_path(); + let source = fs::read_to_string(extension_path).expect("extension source"); + assert!(source.contains("pi.registerTool")); + assert!(!source.contains("SKILL_CONTEXTS")); + assert!(!source.contains("before_agent_start")); + assert!(!source.contains("MNote Pi Lab enabled skill context follows")); + assert!(!source.contains("Use Context7 docs.")); + } + + #[test] + fn skill_cli_args_use_native_skill_without_no_skills() { + let session = PiLabSession { + session_id: "pi_lab_skill_args".into(), + mnote_user_id: "user_test".into(), + bridge_token: "bridge".into(), + status: PiLabSessionStatus::RuntimeRunning, + provider_session_id: "prov_skill".into(), + pi_session_dir: "/tmp/pi-lab/skill-args".into(), + pi_session_file: None, + root_uri: None, + workspace_id: None, + page_path: None, + page_title: None, + model_provider: None, + model_id: None, + thinking_level: Some("medium".into()), + allowed_roots_snapshot: None, + runtime_policy_snapshot: Some(json!({ + "enabledSkillSources": ["/tmp/context7/SKILL.md", "/tmp/vpn/SKILL.md"] + })), + runtime_pid: None, + runtime_mode: "mock".into(), + runtime_error: None, + created_at_ms: 1000, + updated_at_ms: 2000, + message_count: 0, + }; + assert_eq!( + pi_lab_skill_cli_args(&session), + vec![ + "--skill", + "/tmp/context7/SKILL.md", + "--skill", + "/tmp/vpn/SKILL.md" + ] + ); + + let mut no_skill_session = session; + no_skill_session.runtime_policy_snapshot = None; + assert_eq!( + pi_lab_skill_cli_args(&no_skill_session), + vec!["--no-skills"] + ); + } + #[tokio::test] async fn list_sessions_filters_by_current_user_and_pi_profile() { let app = test_app(); @@ -2819,6 +6272,540 @@ mod tests { assert_eq!(payload["count"].as_u64().unwrap_or(0), 0); } + #[tokio::test] + async fn start_uses_ai_settings_for_model_skills_mcp_and_tools() { + std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock"); + std::env::set_var("MNOTE_PAGE_AI_PI_MCP_STRICT_LAZY", "1"); + let state = test_state(); + let actor_id = "pi_policy_user"; + let root = temp_root("mnote-pi-policy-root"); + let root_uri = grant_directory(&state, actor_id, &root, "write"); + upsert_ai_policy( + &state, + actor_id, + json!({ + "defaultModel": "omniroute/pi-fast", + "allowedModels": ["omniroute/pi-fast"], + "tools": { + "mnote.local_file.patch": "deny" + }, + "skills": { + "context7": { + "name": "Context7", + "enabled": false, + "description": "disabled in test", + "source": "/tmp/context7-disabled/SKILL.md", + "riskLevel": "medium", + "requiredScopes": [] + }, + "vpn": { + "name": "VPN", + "enabled": true, + "description": "enabled in test", + "source": "/tmp/vpn-enabled/SKILL.md", + "riskLevel": "high", + "requiredScopes": [] + } + }, + "mcpServers": { + "context7": { + "name": "Context7", + "enabled": false, + "transport": "streamable-http", + "url": "https://mcp.context7.com/mcp", + "command": "", + "networkPolicy": "allow-all", + "secretRefs": [], + "facadeOnly": true, + "sandbox": true, + "description": "disabled in test", + "riskLevel": "medium", + "requiredScopes": [] + }, + "codegraph": { + "name": "CodeGraph", + "enabled": true, + "transport": "stdio", + "url": "", + "command": "codegraph serve --mcp", + "networkPolicy": "deny-all", + "secretRefs": [], + "facadeOnly": true, + "sandbox": true, + "description": "enabled in test", + "riskLevel": "medium", + "requiredScopes": [] + } + } + }), + ); + let app = build_app(state); + let (status, payload) = request_json( + app.clone(), + "/api/page-ai/pi/start", + actor_id, + json!({ + "sessionId": "pi_lab_policy_test", + "rootUri": root_uri, + "modelProvider": "omniroute", + "modelId": "pi-fast", + "thinkingLevel": "high", + "permissionMode": "full_access" + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["mnoteToolOnly"], false); + assert_eq!(payload["managedPiBuiltinTools"], json!([])); + assert!(payload["configuredPiExtensionSources"] + .as_array() + .unwrap() + .contains(&json!("mnote:core"))); + assert!(payload["piExtensionSources"] + .as_array() + .unwrap() + .iter() + .any(|source| { + source.as_str().is_some_and(|value| { + value.ends_with("packages/pi-mnote/extensions/pi-rust-official/question.ts") + }) + })); + assert!(payload["piExtensionSources"] + .as_array() + .unwrap() + .iter() + .any(|source| { + source.as_str().is_some_and(|value| { + value + .ends_with("packages/pi-mnote/extensions/pi-rust-official/questionnaire.ts") + }) + })); + assert_eq!(payload["session"]["modelProvider"], "omniroute"); + assert_eq!(payload["session"]["modelId"], "pi-fast"); + assert_eq!(payload["session"]["thinkingLevel"], "high"); + assert_eq!( + payload["session"]["runtimePolicySnapshot"]["permissionMode"], + "full_access" + ); + let policy = &payload["session"]["runtimePolicySnapshot"]; + assert_eq!(policy["defaultModel"], "omniroute/pi-fast"); + assert_eq!(policy["allowedModels"], json!(["omniroute/pi-fast"])); + assert!(policy["enabledSkills"] + .as_array() + .unwrap() + .contains(&json!("vpn"))); + assert!(!policy["enabledSkills"] + .as_array() + .unwrap() + .contains(&json!("context7"))); + assert!(policy["enabledMcpServers"] + .as_array() + .unwrap() + .contains(&json!("codegraph"))); + assert!(!policy["enabledMcpServers"] + .as_array() + .unwrap() + .contains(&json!("context7"))); + assert_eq!(policy["nativeMcpSupported"], false); + assert_eq!(policy["mcpBridge"], "pi-rust-sync-client"); + assert!(policy["enabledPiExtensions"] + .as_array() + .unwrap() + .contains(&json!("mnote-pi"))); + assert!(policy["enabledPiExtensionSources"] + .as_array() + .unwrap() + .contains(&json!("mnote:core"))); + assert_eq!( + policy["mcpServers"]["codegraph"]["command"], + json!("codegraph") + ); + assert_eq!( + policy["mcpServers"]["codegraph"]["args"], + json!(["serve", "--mcp"]) + ); + assert!(policy["mcpServers"].get("context7").is_none()); + assert!(!policy["mnoteToolNames"] + .as_array() + .unwrap() + .contains(&json!("mnote.local_file.patch"))); + let mcp_config_path = PathBuf::from( + payload["session"]["piSessionDir"] + .as_str() + .expect("session dir"), + ) + .join("config") + .join("mcp.json"); + assert!( + mcp_config_path.exists(), + "built-in Pi Rust MCP extension should receive session mcp.json" + ); + let mcp_config: Value = + serde_json::from_slice(&fs::read(&mcp_config_path).expect("read mcp config")) + .expect("parse mcp config"); + assert_eq!( + mcp_config["mcpServers"]["codegraph"]["command"], + json!("codegraph") + ); + let mcp_cache_path = PathBuf::from( + payload["session"]["piSessionDir"] + .as_str() + .expect("session dir"), + ) + .join("config") + .join(PI_LAB_MCP_CACHE_FILE); + assert!( + mcp_cache_path.exists(), + "MCP-enabled sessions should initialize a session metadata cache" + ); + let full_access_session = get_session("pi_lab_policy_test").expect("full access session"); + assert!(shared_mcp_cache_path(&full_access_session).is_some()); + let permission_config_path = PathBuf::from( + payload["session"]["piSessionDir"] + .as_str() + .expect("session dir"), + ) + .join("config") + .join("extensions") + .join("pi-permission-system") + .join("config.json"); + assert!( + !permission_config_path.exists(), + "permission-system config should not be generated unless external Pi extensions are explicitly enabled" + ); + let full_access_tool_policies = mnote_pi_tool_policies(&full_access_session); + assert_eq!( + full_access_tool_policies["mnote.current_page.read"], + "allow" + ); + assert_eq!( + full_access_tool_policies["mnote.codex_rescue.request"], + "ask" + ); + + let (tool_status, tool_payload) = request_json( + app.clone(), + "/api/page-ai/pi/tool-call", + actor_id, + json!({ + "sessionId": "pi_lab_policy_test", + "toolName": "mnote.local_file.patch", + "params": {"rootUri": root_uri, "path": "note.md", "operations": []} + }), + ) + .await; + assert_eq!(tool_status, StatusCode::FORBIDDEN); + assert_eq!(tool_payload["code"], "page_ai_pi_lab_tool_disabled"); + + let (denied_status, denied_payload) = request_json( + app, + "/api/page-ai/pi/start", + actor_id, + json!({ + "sessionId": "pi_lab_policy_denied_model", + "modelProvider": "omniroute", + "modelId": "not-allowed" + }), + ) + .await; + assert_eq!(denied_status, StatusCode::FORBIDDEN); + assert_eq!(denied_payload["code"], "page_ai_pi_lab_model_not_allowed"); + } + + #[test] + fn hydrate_session_mcp_cache_copies_shared_cache() { + let root = temp_root("mnote-pi-shared-mcp-cache-root"); + let config_dir = root.join("session-config"); + fs::create_dir_all(&config_dir).expect("config dir"); + let session = PiLabSession { + session_id: "pi_lab_cache_copy".into(), + mnote_user_id: "user/cache".into(), + bridge_token: "bridge".into(), + status: PiLabSessionStatus::RuntimeRunning, + provider_session_id: "prov_cache".into(), + pi_session_dir: root.join("session").to_string_lossy().to_string(), + pi_session_file: None, + root_uri: Some(file_uri(&root)), + workspace_id: None, + page_path: None, + page_title: None, + model_provider: None, + model_id: None, + thinking_level: Some("medium".into()), + allowed_roots_snapshot: None, + runtime_policy_snapshot: Some(json!({ + "mcpBridge": "pi-rust-sync-client", + "enabledMcpServers": ["codegraph"], + "mcpServers": { + "codegraph": { + "transport": "stdio", + "command": "codegraph", + "args": ["serve", "--mcp"], + "lifecycle": "lazy" + } + } + })), + runtime_pid: None, + runtime_mode: "mock".into(), + runtime_error: None, + created_at_ms: 1000, + updated_at_ms: 2000, + message_count: 0, + }; + let shared = shared_mcp_cache_path(&session).expect("shared cache path"); + fs::create_dir_all(shared.parent().expect("shared parent")).expect("shared parent"); + fs::write( + &shared, + br#"{"version":1,"servers":{"codegraph":{"configHash":"hash","tools":[],"resources":[],"cachedAt":1}}}"#, + ) + .expect("shared cache write"); + + let hydrated = hydrate_session_mcp_cache(&session, &config_dir) + .expect("hydrate") + .expect("shared path"); + assert_eq!(hydrated, shared); + let session_cache = + fs::read_to_string(config_dir.join(PI_LAB_MCP_CACHE_FILE)).expect("session cache"); + assert!(session_cache.contains("\"codegraph\"")); + } + + #[tokio::test] + async fn tool_facade_enforces_directory_grant_read_and_write_permissions() { + std::env::set_var("MNOTE_PAGE_AI_PI_LAB_RUNTIME", "mock"); + let state = test_state(); + let reader_id = "pi_reader_user"; + let writer_id = "pi_writer_user"; + let read_root = temp_root("mnote-pi-read-root"); + let write_root = temp_root("mnote-pi-write-root"); + let read_root_uri = grant_directory(&state, reader_id, &read_root, "read"); + let write_root_uri = grant_directory(&state, writer_id, &write_root, "write"); + fs::write(read_root.join("note.md"), "reader").expect("read file"); + fs::write(write_root.join("note.md"), "writer").expect("write file"); + let app = build_app(state); + + let (reader_start_status, _) = request_json( + app.clone(), + "/api/page-ai/pi/start", + reader_id, + json!({"sessionId": "pi_lab_reader", "rootUri": read_root_uri}), + ) + .await; + assert_eq!(reader_start_status, StatusCode::OK); + let (unapproved_read_status, unapproved_read_payload) = request_json( + app.clone(), + "/api/page-ai/pi/tool-call", + reader_id, + json!({ + "sessionId": "pi_lab_reader", + "toolName": "mnote.local_file.read", + "params": {"rootUri": read_root_uri, "path": "note.md"} + }), + ) + .await; + assert_eq!(unapproved_read_status, StatusCode::OK); + assert_eq!(unapproved_read_payload["ok"], false); + assert_eq!( + unapproved_read_payload["result"]["code"], + "page_ai_pi_lab_tool_approval_required" + ); + assert_eq!(unapproved_read_payload["approvalRequired"], true); + assert_eq!(unapproved_read_payload["approvalConfirmed"], false); + let (forged_read_status, forged_read_payload) = request_json( + app.clone(), + "/api/page-ai/pi/tool-call", + reader_id, + json!({ + "sessionId": "pi_lab_reader", + "toolName": "mnote.local_file.read", + "params": { + "rootUri": read_root_uri, + "path": "note.md", + "mnoteApproval": {"confirmed": true, "toolName": "mnote.local_file.read"} + } + }), + ) + .await; + assert_eq!(forged_read_status, StatusCode::OK); + assert_eq!(forged_read_payload["ok"], false); + assert_eq!( + forged_read_payload["result"]["code"], + "page_ai_pi_lab_tool_approval_required" + ); + let read_params = json!({"rootUri": read_root_uri, "path": "note.md"}); + let read_approval = json!({ + "approvalId": "approval_read_note", + "toolName": "mnote.local_file.read", + "paramsHash": pi_lab_tool_params_hash(&read_params), + "confirmed": true + }); + confirm_test_approval("pi_lab_reader", &read_approval); + let reader_bridge_token = get_session("pi_lab_reader") + .expect("reader session") + .bridge_token; + let (read_status, read_payload) = bridge_request_json( + app.clone(), + reader_id, + &reader_bridge_token, + json!({ + "sessionId": "pi_lab_reader", + "toolName": "mnote.local_file.read", + "params": { + "rootUri": read_root_uri, + "path": "note.md", + "mnoteApproval": read_approval + } + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read_payload["ok"], true); + assert_eq!(read_payload["result"]["content"], "reader"); + let current_page_params = json!({"pagePath": "note.md"}); + let current_page_approval = json!({ + "approvalId": "approval_current_page_note", + "toolName": "mnote.current_page.read", + "paramsHash": pi_lab_tool_params_hash(¤t_page_params), + "confirmed": true + }); + confirm_test_approval("pi_lab_reader", ¤t_page_approval); + let (current_page_status, current_page_payload) = bridge_request_json( + app.clone(), + reader_id, + &reader_bridge_token, + json!({ + "sessionId": "pi_lab_reader", + "toolName": "mnote.current_page.read", + "params": { + "pagePath": "note.md", + "mnoteApproval": current_page_approval + } + }), + ) + .await; + assert_eq!(current_page_status, StatusCode::OK); + assert_eq!(current_page_payload["ok"], true); + assert_eq!(current_page_payload["result"]["rootUri"], read_root_uri); + assert_eq!(current_page_payload["result"]["content"], "reader"); + let nested_read_root = read_root.join("nested-current-page-root"); + fs::create_dir_all(&nested_read_root).expect("nested root"); + fs::write(nested_read_root.join("child.md"), "nested reader").expect("nested reader file"); + let nested_read_root_uri = format!("file://{}", nested_read_root.display()); + let nested_current_page_params = + json!({"rootUri": nested_read_root_uri, "pagePath": "child.md"}); + let nested_current_page_approval = json!({ + "approvalId": "approval_nested_current_page_child", + "toolName": "mnote.current_page.read", + "paramsHash": pi_lab_tool_params_hash(&nested_current_page_params), + "confirmed": true + }); + confirm_test_approval("pi_lab_reader", &nested_current_page_approval); + let (nested_current_page_status, nested_current_page_payload) = bridge_request_json( + app.clone(), + reader_id, + &reader_bridge_token, + json!({ + "sessionId": "pi_lab_reader", + "toolName": "mnote.current_page.read", + "params": { + "rootUri": nested_read_root_uri, + "pagePath": "child.md", + "mnoteApproval": nested_current_page_approval + } + }), + ) + .await; + assert_eq!(nested_current_page_status, StatusCode::OK); + assert_eq!(nested_current_page_payload["ok"], true); + assert_eq!( + nested_current_page_payload["result"]["rootUri"], + nested_read_root_uri + ); + assert_eq!( + nested_current_page_payload["result"]["content"], + "nested reader" + ); + let readonly_patch_params = json!({ + "rootUri": read_root_uri, + "path": "note.md", + "operations": [{"op": "append", "content": " updated"}] + }); + let readonly_patch_approval = json!({ + "approvalId": "approval_readonly_patch", + "toolName": "mnote.local_file.patch", + "paramsHash": pi_lab_tool_params_hash(&readonly_patch_params), + "confirmed": true + }); + confirm_test_approval("pi_lab_reader", &readonly_patch_approval); + let (readonly_patch_status, readonly_patch_payload) = bridge_request_json( + app.clone(), + reader_id, + &reader_bridge_token, + json!({ + "sessionId": "pi_lab_reader", + "toolName": "mnote.local_file.patch", + "params": { + "rootUri": read_root_uri, + "path": "note.md", + "operations": [{"op": "append", "content": " updated"}], + "mnoteApproval": readonly_patch_approval + } + }), + ) + .await; + assert_eq!(readonly_patch_status, StatusCode::OK); + assert_eq!(readonly_patch_payload["ok"], false); + assert_eq!( + readonly_patch_payload["result"]["code"], + "page_ai_pi_lab_root_readonly" + ); + + let (writer_start_status, _) = request_json( + app.clone(), + "/api/page-ai/pi/start", + writer_id, + json!({"sessionId": "pi_lab_writer", "rootUri": write_root_uri}), + ) + .await; + assert_eq!(writer_start_status, StatusCode::OK); + let write_params = json!({ + "rootUri": write_root_uri, + "path": "note.md", + "operations": [{"op": "append", "content": " updated"}] + }); + let write_approval = json!({ + "approvalId": "approval_write_note", + "toolName": "mnote.local_file.patch", + "paramsHash": pi_lab_tool_params_hash(&write_params), + "confirmed": true + }); + confirm_test_approval("pi_lab_writer", &write_approval); + let writer_bridge_token = get_session("pi_lab_writer") + .expect("writer session") + .bridge_token; + let (write_status, write_payload) = bridge_request_json( + app, + writer_id, + &writer_bridge_token, + json!({ + "sessionId": "pi_lab_writer", + "toolName": "mnote.local_file.patch", + "params": { + "rootUri": write_root_uri, + "path": "note.md", + "operations": [{"op": "append", "content": " updated"}], + "mnoteApproval": write_approval + } + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write_payload["ok"], true); + assert_eq!( + fs::read_to_string(write_root.join("note.md")).expect("patched file"), + "writer updated" + ); + } + #[tokio::test] async fn get_session_returns_not_found_for_nonexistent_session() { let app = test_app(); diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 608f8515..910a8e6a 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -3570,6 +3570,70 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[tokio::test] + async fn tree_command_local_folder_page_bundle_folder_delete_is_idempotent() { + let root = std::env::temp_dir().join(format!( + "mnote-local-page-bundle-folder-delete-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("Bundle")).expect("create bundle"); + std::fs::write(root.join("Bundle").join("Bundle.md"), "# Bundle\n").expect("write page"); + std::fs::write(root.join("Bundle").join("asset.txt"), "asset").expect("write asset"); + let root_uri = format!("file://{}", root.display()); + init_local_workspace(&root, "user_test"); + let folder_id = format!( + "local-dir:{}", + crate::routes::local_folder_source::encode_local_id_segment("Bundle") + ); + + for attempt in 0..2 { + let response = app() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"action":"archive","sourceKind":"local_folder","rootUri":"{root_uri}","documentId":"{folder_id}"}}"# + ))) + .expect("request"), + ) + .await + .expect("response"); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!( + status, + StatusCode::OK, + "attempt={attempt} body={}", + String::from_utf8_lossy(&body) + ); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!( + payload["result"]["execution"]["resourceKind"].as_str(), + Some("markdown_bundle") + ); + if attempt == 1 { + assert_eq!( + payload["result"]["execution"]["idempotent"].as_bool(), + Some(true) + ); + } + } + + assert!(!root.join("Bundle").exists()); + assert!(root + .join(".mnote") + .join("trash") + .join("Bundle") + .join("Bundle.md") + .exists()); + let _ = std::fs::remove_dir_all(&root); + } + #[tokio::test] async fn tree_command_local_folder_root_escape_returns_unified_error_envelope() { let root = std::env::temp_dir().join(format!( diff --git a/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs b/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs index f01e660f..664c5a1a 100644 --- a/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs +++ b/rust/crates/mnote-web/src/ssr/pages/ai_admin.rs @@ -1,7 +1,7 @@ //! MNOTE AI 管理页面组件 //! //! 可供 /admin/ai 和 /user/ai 使用的 Leptos SSR 管理页面。 -//! 包含 Overview、Models、Tools、Skills/MCP、Access Scopes 等面板。 +//! 包含 Overview、Models、Tools、Skills/MCP、目录权限等面板。 //! 用户模式下隐藏管理写操作。写管理使用 GET/PUT /api/ai-admin/settings。 //! 模型/工具/技能面板通过 JS 渲染内联编辑表单,保存后 PUT 全量配置。 @@ -537,7 +537,27 @@ const AI_ADMIN_STYLE: &str = r#" border-bottom: 1px solid #f0f0f0; background: #fafafa; } +details.mnote-ai-admin-provider-card > summary.mnote-ai-admin-provider-card-header { + cursor: pointer; + list-style: none; +} +details.mnote-ai-admin-provider-card:not([open]) > summary.mnote-ai-admin-provider-card-header { + border-bottom: 0; +} +details.mnote-ai-admin-provider-card > summary.mnote-ai-admin-provider-card-header::-webkit-details-marker { + display: none; +} +details.mnote-ai-admin-provider-card > summary.mnote-ai-admin-provider-card-header::before { + content: "▸"; + color: #8b8782; + font-size: 11px; +} +details.mnote-ai-admin-provider-card[open] > summary.mnote-ai-admin-provider-card-header::before { + content: "▾"; +} .mnote-ai-admin-provider-card-title { + flex: 1; + min-width: 0; font-size: 14px; font-weight: 600; } @@ -800,6 +820,35 @@ const AI_ADMIN_STYLE: &str = r#" color: rgba(0,0,0,0.45); font-size: 12px; } +.mnote-ai-admin-directory-form { + display: grid; + grid-template-columns: minmax(240px, 1fr) 140px auto; + gap: 10px; + align-items: end; + margin-bottom: 14px; + padding: 12px; + border: 1px solid #f0f0f0; + border-radius: 8px; + background: #fafafa; +} +.mnote-ai-admin-directory-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 130px auto; + gap: 10px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid #f0f0f0; +} +.mnote-ai-admin-directory-row:last-child { + border-bottom: 0; +} +.mnote-ai-admin-directory-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} .mnote-ai-admin-drawer { position: fixed; inset: 0; @@ -875,7 +924,10 @@ const AI_ADMIN_STYLE: &str = r#" } .mnote-ai-admin-section-header, .mnote-ai-admin-search-row, - .mnote-ai-admin-access-scope-row { + .mnote-ai-admin-access-scope-row, + .mnote-ai-admin-directory-form, + .mnote-ai-admin-directory-row { + grid-template-columns: 1fr; flex-direction: column; align-items: flex-start; } @@ -915,6 +967,7 @@ const AI_ADMIN_SCRIPT: &str = r#" var channelsPanel = root.querySelector('[data-ai-admin-channels-panel]'); var knowledgePanel = root.querySelector('[data-ai-admin-knowledge-panel]'); var accessScopesList = root.querySelector('[data-ai-admin-access-scopes-list]'); + var accessRequestsList = root.querySelector('[data-ai-admin-access-requests-list]'); var accessScopesJson = root.querySelector('[data-ai-admin-access-scopes-json]'); var accessScopesMessage = root.querySelector('[data-ai-admin-access-scopes-message]'); var effectiveJson = root.querySelector('[data-ai-admin-effective-json]'); @@ -927,9 +980,12 @@ const AI_ADMIN_SCRIPT: &str = r#" var toolsContainer = root.querySelector('[data-ai-admin-tools-container]'); var skillsContainer = root.querySelector('[data-ai-admin-skills-container]'); var mcpContainer = root.querySelector('[data-ai-admin-mcp-container]'); + var piExtensionsContainer = root.querySelector('[data-ai-admin-pi-extensions-container]'); var modelsSaveStatus = root.querySelector('[data-ai-admin-models-save-status]'); var toolsSaveStatus = root.querySelector('[data-ai-admin-tools-save-status]'); var skillsSaveStatus = root.querySelector('[data-ai-admin-skills-save-status]'); + var mcpSaveStatus = root.querySelector('[data-ai-admin-mcp-save-status]'); + var piExtensionsSaveStatus = root.querySelector('[data-ai-admin-pi-extensions-save-status]'); var usersList = root.querySelector('[data-ai-admin-users-list]'); var userSettingsEditor = root.querySelector('[data-ai-admin-user-settings]'); var userSettingsStatus = root.querySelector('[data-ai-admin-user-save-status]'); @@ -1025,6 +1081,86 @@ const AI_ADMIN_SCRIPT: &str = r#" return '
    ' + escapeHtml(label) + '' + escapeHtml(value == null || value === '' ? '-' : value) + '
    '; } + function normalizeDirectoryGrants(payload) { + var grants = payload && (payload.grants || (payload.policy && payload.policy.grants) || payload.allowedRoots || payload.allowed_roots || []); + return Array.isArray(grants) ? grants.filter(Boolean) : []; + } + + function activeDirectoryGrants(grants) { + return (grants || []).filter(function(grant) { + return grant && grant.active !== false && grant.status !== 'revoked'; + }); + } + + function directoryGrantKey(grant) { + return String(grant.rootUri || grant.root_uri || grant.rootPath || grant.root_path || grant.path || '').trim(); + } + + function isDefaultWorkspaceAutoGrant(grant) { + var source = String(grant && grant.source || '').trim(); + var workspaceId = String(grant && (grant.workspaceId || grant.workspace_id) || '').trim(); + var rootUri = String(grant && (grant.rootUri || grant.root_uri) || '').trim(); + var permission = String(grant && grant.permission || '').trim(); + var createdBy = String(grant && (grant.createdBy || grant.created_by) || '').trim(); + var targetUser = String(grant && (grant.userId || grant.user_id) || '').trim(); + return source === 'auto' && workspaceId && permission === 'write' && createdBy === targetUser && + rootUri.indexOf('local://users/') === 0 && rootUri.indexOf('/workspaces/my-space') !== -1; + } + + function grantDisplayPath(grant) { + return String(grant.rootPath || grant.root_path || grant.rootUri || grant.root_uri || grant.path || '').trim(); + } + + function permissionLabel(permission) { + return permission === 'write' ? '读写' : '只读'; + } + + function knownUserIds() { + return (window.__MNOTE_AI_ADMIN_USERS__ || []).map(function(user) { + return String(user && user.id || '').trim(); + }).filter(Boolean); + } + + async function loadAdminAccessPolicy() { + if (!isAdmin) return { grants: [] }; + return requestJson('/api/admin/access-policy', { method: 'GET' }); + } + + async function createDirectoryGrant(userId, rootPath, permission) { + return requestJson('/api/admin/access-policy/grants', { + method: 'POST', + body: JSON.stringify({ + userId: userId, + rootPath: rootPath, + permission: permission || 'read', + recursive: true, + capabilities: [] + }) + }); + } + + async function deleteDirectoryGrant(grantId) { + return requestJson('/api/admin/access-policy/grants/' + encodeURIComponent(grantId), { method: 'DELETE' }); + } + + async function createDirectoryAccessRequest(rootPath, permission, note) { + return requestJson('/api/settings/directory-access-requests', { + method: 'POST', + body: JSON.stringify({ + rootPath: rootPath, + permission: permission || 'read', + note: note || '' + }) + }); + } + + async function decideDirectoryAccessRequest(requestId, action, reason) { + return requestJson('/api/admin/settings/directory-access-requests/' + encodeURIComponent(requestId) + '/' + action, { + method: 'POST', + body: JSON.stringify({ reason: reason || '' }) + }); + } + function actionLabel(action) { if (action === 'deny') return '拒绝'; if (action === 'ask') return '审批'; @@ -1070,48 +1206,61 @@ const AI_ADMIN_SCRIPT: &str = r#" function renderServicePanels(piStatus, effectivePayload) { var providers = effectivePayload && Array.isArray(effectivePayload.providers) ? effectivePayload.providers : []; - var openhubStatus = '默认入口保留'; + var openhubStatus = '兼容保留'; var defaultModel = effectivePayload && (effectivePayload.defaultModel || effectivePayload.default_model || ''); var piDefaultModel = piStatus ? normalizeModelRef(piStatus.defaultModelProvider || piStatus.default_model_provider, piStatus.defaultModelId || piStatus.default_model_id) : 'omniroute/freefirst'; var piRuntime = piStatus ? (piStatus.runtimeMode || piStatus.runtime_mode || '-') : '-'; + var piRuntimeImpl = piStatus ? (piStatus.runtimeImplementation || piStatus.runtime_implementation || 'pi-rust') : 'pi-rust'; + var piRuntimeBinary = piStatus ? (piStatus.runtimeBinary || piStatus.runtime_binary || '-') : '-'; + var piRuntimeAvailable = !piStatus || piStatus.runtimeAvailable !== false; + var piRuntimeError = piStatus && (piStatus.runtimeError || piStatus.runtime_error || piStatus.runtimeInstallHint || piStatus.runtime_install_hint || ''); var piRunning = piStatus && piStatus.running === true; + var piExtensions = piStatus && Array.isArray(piStatus.piExtensionSources) + ? piStatus.piExtensionSources + : (effectivePayload && Array.isArray(effectivePayload.piExtensions) ? effectivePayload.piExtensions.map(function(item) { return item.source || item.name || item.id; }).filter(Boolean) : []); if (servicePanel) { servicePanel.innerHTML = '
    ' + '
    ' + - '

    OpenHub

    ' + - renderTag(openhubStatus, 'green') + - '打开 OpenHub Admin' + - '
    ' + - '
    ' + - '
    ' + - renderKv('定位', '默认 Page AI 主线') + - renderKv('Provider/模型真相', 'OpenHub 保持独立,不被 Pi Lab 替换') + - renderKv('MNote 侧入口', '/page-ai/openhub/ai') + - renderKv('管理入口', '/page-ai/openhub/admin') + - '
    ' + - '
    ' + - '
    ' + - '
    ' + - '

    Pi Lab

    ' + - renderTag(piRunning ? '运行中' : '待启动', piRunning ? 'green' : 'orange') + + '

    Pi Rust Page AI

    ' + + renderTag(piRunning ? '运行中' : (piRuntimeAvailable ? '可启动' : '不可用'), piRunning ? 'green' : (piRuntimeAvailable ? 'orange' : 'red')) + + renderTag(piRuntimeImpl, 'blue') + renderTag(piRuntime, 'blue') + '
    ' + '
    ' + '
    ' + + renderKv('定位', '默认 Page AI runtime,由 MNote Rust 后端托管') + renderKv('默认模型', piDefaultModel) + + renderKv('Runtime binary', piRuntimeBinary) + + renderKv('Runtime 可用性', piRuntimeAvailable ? 'available' : 'missing') + + renderKv('Runtime error', piRuntimeError || 'none') + renderKv('UI 模式', piStatus && piStatus.uiMode ? piStatus.uiMode : 'independent_mnote_native_drawer') + renderKv('Session', piStatus && piStatus.sessionId ? piStatus.sessionId : 'none') + renderKv('Provider Session', piStatus && piStatus.providerSessionId ? piStatus.providerSessionId : 'none') + renderKv('Runtime PID', piStatus && piStatus.pid ? String(piStatus.pid) : 'none') + - renderKv('禁用 Pi 原始工具', (piStatus && piStatus.disabledPiBuiltinTools || ['bash','read','write','edit']).join(', ')) + + renderKv('受控 Pi 原生工具', (piStatus && piStatus.managedPiBuiltinTools || ['read','write','edit','bash','grep','find','ls','hashline_edit']).join(', ')) + + renderKv('Pi 扩展包', piExtensions.length ? piExtensions.join(', ') : '待 runtime 启动') + renderKv('Receipt storage', piStatus && piStatus.receiptStorage ? piStatus.receiptStorage : 'control-plane / provider-neutral adapter') + renderKv('Session dir policy', piStatus && piStatus.managedPiSessionDirPolicy ? piStatus.managedPiSessionDirPolicy : '/.mnote/ai/pi-sessions//') + '
    ' + '
    ' + '
    ' + + '
    ' + + '

    OpenHub

    ' + + renderTag(openhubStatus, 'orange') + + '打开 OpenHub Admin' + + '
    ' + + '
    ' + + '
    ' + + renderKv('定位', '迁移期兼容 / admin 边界,不再作为默认 Page AI chat') + + renderKv('MNote 侧入口', '/page-ai/openhub/ai') + + renderKv('管理入口', '/page-ai/openhub/admin') + + renderKv('后续策略', '只保留一个用户 Page AI 入口,OpenHub 不再扩展重复面板能力') + + '
    ' + + '
    ' + + '
    ' + '
    '; } if (channelsPanel) { @@ -1176,8 +1325,8 @@ const AI_ADMIN_SCRIPT: &str = r#" } if (healthGrid) { healthGrid.innerHTML = [ - ['OpenHub 默认入口', '保留', 'Page AI 主线未被 Pi Lab 替换'], - ['Pi Lab', piRunning ? '运行中' : '可启动', 'runtime=' + piRuntime + ' · model=' + piDefaultModel], + ['Pi Rust Page AI', piRunning ? '运行中' : (piRuntimeAvailable ? '可启动' : '不可用'), 'impl=' + piRuntimeImpl + ' · runtime=' + piRuntime + ' · model=' + piDefaultModel], + ['OpenHub', '兼容保留', '迁移期 admin / fallback 边界,不再作为默认 Page AI chat'], ['LightRAG', '默认', '唯一默认 knowledge provider,Pi 通过 MNote facade 查询'], ['Control-plane', effectivePayload && (effectivePayload.sourceOfTruth || effectivePayload.source_of_truth) || 'directory_grants', '授权、策略、receipt 真相层'] ].map(function(item) { @@ -1246,9 +1395,9 @@ const AI_ADMIN_SCRIPT: &str = r#" if (!userSettingsEditor) return; selectedUserSettings = payload; if (drawerTitle) drawerTitle.textContent = payload.userId || '用户配置'; - if (drawerSubtitle) drawerSubtitle.textContent = '逐用户模型 / 工具 / Skills / MCP / 目录权限'; + if (drawerSubtitle) drawerSubtitle.textContent = '逐用户模型 / 工具 / Skills / MCP / Pi 扩展 / 目录权限'; if (payload.isGlobalPolicyOwner) { - userSettingsEditor.innerHTML = '
    该用户承载全局 AI 策略。请使用左侧“模型配置”“工具权限”“技能 / MCP”页面修改默认策略。
    '; + userSettingsEditor.innerHTML = '
    该用户承载全局 AI 策略。请使用左侧“模型配置”“工具权限”“Skills / MCP / Pi 扩展”页面修改默认策略。
    '; return; } var catalogModels = payload.catalogModels || []; @@ -1329,10 +1478,37 @@ const AI_ADMIN_SCRIPT: &str = r#" }); }).join(''); if (mcps) mcps = renderPermissionGroup('MCP Servers', (payload.mcpServers || []).length + ' 项', mcps); - var roots = (payload.allowedRoots || payload.accessScopes || payload.roots || []).map(function(rootItem) { - return '
    ' + escapeHtml(rootItem.rootPath || rootItem.path || rootItem.rootUri || '-') + '
    ' + - '
    ' + escapeHtml(rootItem.source || rootItem.scope || 'directory_grants') + '
    ' + - renderTag(rootItem.permission === 'write' ? '读写' : '只读', rootItem.permission === 'write' ? 'green' : '') + '
    '; + var enabledPiExtensions = (payload.piExtensions || []).filter(function(extension) { return extension.globallyEnabled !== false; }); + var piExtensions = (payload.piExtensions || []).map(function(extension) { + var toolNames = Array.isArray(extension.toolNames) ? extension.toolNames.join(', ') : ''; + return renderSelectablePermissionRow({ + title: extension.name || extension.id, + subtitle: (extension.source || 'npm package') + (toolNames ? (' · tools: ' + toolNames) : ''), + metaHtml: renderTag(extension.enabled ? '启用' : '禁用', extension.enabled ? 'green' : 'red'), + selectHtml: '', + resetHtml: extension.hasOverride ? '' : '' + }); + }).join(''); + if (piExtensions) piExtensions = renderPermissionGroup('Pi 扩展包', enabledPiExtensions.length + '/' + (payload.piExtensions || []).length + ' 全局可用', piExtensions); + var userRoots = activeDirectoryGrants(payload.allowedRoots || payload.accessScopes || payload.roots || []); + var roots = userRoots.map(function(rootItem) { + var readonly = isDefaultWorkspaceAutoGrant(rootItem) || !rootItem.id; + return '
    ' + + '
    ' + + '' + + '
    ' + escapeHtml(rootItem.source || rootItem.scope || 'directory_grants') + (readonly ? ' · 系统默认' : '') + '
    ' + + '
    ' + + '' + + '' + + (readonly ? renderTag(permissionLabel(rootItem.permission), rootItem.permission === 'write' ? 'green' : '') : + '' + + '') + + '' + + '
    '; }).join(''); var activePanel = selectedUserPanel || 'models'; var header = @@ -1341,6 +1517,7 @@ const AI_ADMIN_SCRIPT: &str = r#" '' + '' + '' + + '' + '' + '' + '
    '; @@ -1351,8 +1528,16 @@ const AI_ADMIN_SCRIPT: &str = r#" body = '

    Skills

    全局启用后用户可单独降权
    ' + (skills || '
    暂无 Skill
    ') + '
    '; } else if (activePanel === 'mcp') { body = '

    MCP

    只允许 MNote facade/sandbox MCP
    ' + (mcps || '
    暂无 MCP
    ') + '
    '; + } else if (activePanel === 'pi-extensions') { + body = '

    Pi 扩展包

    用户只能在管理员全局启用范围内降权
    ' + (piExtensions || '
    暂无 Pi 扩展包
    ') + '
    '; } else if (activePanel === 'roots') { - body = '

    目录权限

    统一授权管理
    ' + (roots || '
    目录权限由统一 allowed roots 管理。请到目录权限页调整。
    ') + '
    '; + body = '

    目录权限

    单独调整该用户的目录 allowed roots
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + (roots || '
    暂无可单独管理的目录权限
    ') + '
    '; } else { body = '

    模型权限

    按 provider 分组,参考 OpenHub 用户模型权限
    ' + (modelRows || '
    暂无可用模型
    ') + '' + + '
    ' + escapeHtml(coverage.length + '/' + users.length + ' 个用户 · ' + (first.source || 'directory_grants')) + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '
    '; + }).join(''); + accessScopesList.innerHTML = + '
    ' + + '

    默认目录授权

    默认授予所有用户
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + (rows || '
    暂无默认目录授权
    ') + + '
    ' + + '
    '; + accessScopesList.querySelectorAll('[data-action="add-admin-root"]').forEach(function(button) { + button.addEventListener('click', async function() { + var pathEl = accessScopesList.querySelector('[data-admin-root-new-path]'); + var permissionEl = accessScopesList.querySelector('[data-admin-root-new-permission]'); + await applyAdminDirectoryGrant([], pathEl ? pathEl.value.trim() : '', permissionEl ? permissionEl.value : 'write'); + }); + }); + accessScopesList.querySelectorAll('[data-action="delete-admin-root"]').forEach(function(button) { + button.addEventListener('click', async function() { + var row = button.closest('[data-admin-root-key]'); + var key = row ? row.getAttribute('data-admin-root-key') : ''; + await revokeAdminDirectoryGroup(groups[key] || []); + }); + }); + accessScopesList.querySelectorAll('[data-action="save-admin-root"]').forEach(function(button) { + button.addEventListener('click', async function() { + var row = button.closest('[data-admin-root-key]'); + var key = row ? row.getAttribute('data-admin-root-key') : ''; + var pathEl = row ? row.querySelector('[data-admin-root-path]') : null; + var permissionEl = row ? row.querySelector('[data-admin-root-permission]') : null; + await applyAdminDirectoryGrant(groups[key] || [], pathEl ? pathEl.value.trim() : '', permissionEl ? permissionEl.value : 'read'); + }); + }); + } + + async function revokeAdminDirectoryGroup(grants) { + try { + for (var i = 0; i < grants.length; i += 1) { + if (grants[i] && grants[i].id) await deleteDirectoryGrant(grants[i].id); + } + showSaveStatus(accessScopesMessage, 'success', '默认目录授权已删除'); + await loadAccessScopes(); + } catch (err) { + showSaveStatus(accessScopesMessage, 'error', '默认目录授权删除失败: ' + (err.message || '未知错误')); + } + } + + async function applyAdminDirectoryGrant(existingGrants, rootPath, permission) { + if (!rootPath) return; + var users = knownUserIds(); + if (!users.length) { + showSaveStatus(accessScopesMessage, 'error', '没有可授权用户'); + return; + } + try { + for (var i = 0; i < existingGrants.length; i += 1) { + if (existingGrants[i] && existingGrants[i].id) await deleteDirectoryGrant(existingGrants[i].id); + } + for (var j = 0; j < users.length; j += 1) { + await createDirectoryGrant(users[j], rootPath, permission || 'read'); + } + showSaveStatus(accessScopesMessage, 'success', '默认目录授权已保存'); + await loadAccessScopes(); + } catch (err) { + showSaveStatus(accessScopesMessage, 'error', '默认目录授权保存失败: ' + (err.message || '未知错误')); + } + } + + async function loadDirectoryAccessRequests() { + if (!accessRequestsList) return; + var url = isAdmin ? '/api/admin/settings/directory-access-requests' : '/api/settings/directory-access-requests'; + try { + var payload = await requestJson(url, { method: 'GET' }); + var requests = Array.isArray(payload.requests) ? payload.requests : []; + accessRequestsList.innerHTML = isAdmin + ? renderAdminDirectoryAccessRequests(requests) + : renderUserDirectoryAccessRequests(requests); + bindDirectoryAccessRequestActions(); + } catch (err) { + accessRequestsList.innerHTML = '
    权限申请加载失败:' + escapeHtml(err.message || '未知错误') + '
    '; + } + } + + function requestStatusLabel(status) { + if (status === 'approved') return '已通过'; + if (status === 'rejected') return '已拒绝'; + return '待审批'; + } + + function requestStatusTone(status) { + if (status === 'approved') return 'green'; + if (status === 'rejected') return 'red'; + return 'orange'; + } + + function renderDirectoryRequestRows(requests, adminMode) { + if (!requests.length) return '
    暂无权限申请
    '; + return requests.map(function(request) { + var status = request.status || 'pending'; + var path = request.rootPath || request.rootUri || '-'; + var user = request.userId || '-'; + var note = request.note ? (' · ' + request.note) : ''; + return '
    ' + + '
    ' + escapeHtml(path) + '
    ' + + '
    ' + escapeHtml(user + ' · ' + permissionLabel(request.permission) + note) + '
    ' + + renderTag(requestStatusLabel(status), requestStatusTone(status)) + + '' + + (adminMode && status === 'pending' + ? '' + + '' + : '' + escapeHtml(request.decidedAt || request.createdAt || '') + '') + + '' + + '
    '; + }).join(''); + } + + function renderUserDirectoryAccessRequests(requests) { + return '
    ' + + '

    申请目录权限

    提交后由管理员审批
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '

    我的申请

    ' + + '
    ' + renderDirectoryRequestRows(requests, false) + '
    ' + + '
    '; + } + + function renderAdminDirectoryAccessRequests(requests) { + return '
    ' + + '

    权限申请

    通过后自动写入该用户 directory_grants
    ' + + '
    ' + renderDirectoryRequestRows(requests, true) + '
    ' + + '
    '; + } + + function bindDirectoryAccessRequestActions() { + if (!accessRequestsList) return; + accessRequestsList.querySelectorAll('[data-action="create-directory-request"]').forEach(function(button) { + button.addEventListener('click', async function() { + var pathEl = accessRequestsList.querySelector('[data-directory-request-path]'); + var permissionEl = accessRequestsList.querySelector('[data-directory-request-permission]'); + var noteEl = accessRequestsList.querySelector('[data-directory-request-note]'); + var rootPath = pathEl ? pathEl.value.trim() : ''; + if (!rootPath) return; + try { + await createDirectoryAccessRequest(rootPath, permissionEl ? permissionEl.value : 'read', noteEl ? noteEl.value.trim() : ''); + showSaveStatus(accessScopesMessage, 'success', '目录权限申请已提交'); + await loadDirectoryAccessRequests(); + } catch (err) { + showSaveStatus(accessScopesMessage, 'error', '目录权限申请失败: ' + (err.message || '未知错误')); + } + }); + }); + accessRequestsList.querySelectorAll('[data-action="approve-directory-request"], [data-action="reject-directory-request"]').forEach(function(button) { + button.addEventListener('click', async function() { + var row = button.closest('[data-directory-request-id]'); + var requestId = row ? row.getAttribute('data-directory-request-id') : ''; + if (!requestId) return; + var action = button.getAttribute('data-action') === 'approve-directory-request' ? 'approve' : 'reject'; + try { + await decideDirectoryAccessRequest(requestId, action, ''); + showSaveStatus(accessScopesMessage, 'success', action === 'approve' ? '目录权限申请已通过' : '目录权限申请已拒绝'); + await loadDirectoryAccessRequests(); + await loadAccessScopes(); + } catch (err) { + showSaveStatus(accessScopesMessage, 'error', '权限申请处理失败: ' + (err.message || '未知错误')); + } + }); + }); + } + async function loadSessionsAndReceipts() { var sessionsPayload = await requestJson('/api/page-ai/pi/sessions?limit=20', { method: 'GET' }); var receiptsUrl = isAdmin ? '/api/ai-admin/receipts?limit=50' : '/api/ai-settings/receipts?limit=50'; @@ -1605,7 +2084,7 @@ const AI_ADMIN_SCRIPT: &str = r#" // ════════════════════════════════════════════════ // configData 保存最后一次 GET 到的全量配置 - var configData = { providers: [], toolPolicies: [], skills: [], mcpServers: [] }; + var configData = { providers: [], toolPolicies: [], skills: [], mcpServers: [], piExtensions: [] }; function objectValuesWithId(value) { return Object.keys(value || {}).map(function(id) { @@ -1796,6 +2275,18 @@ const AI_ADMIN_SCRIPT: &str = r#" } } + function keepSummaryControlsInteractive(container) { + if (!container) return; + container.querySelectorAll('summary .mnote-ai-admin-toggle, summary .mnote-ai-admin-btn').forEach(function(control) { + control.addEventListener('click', function(event) { + event.stopPropagation(); + }); + control.addEventListener('keydown', function(event) { + event.stopPropagation(); + }); + }); + } + function renderMCPConfig(mcpServers) { if (!mcpContainer) return; if (!mcpServers || !mcpServers.length) { @@ -1805,15 +2296,15 @@ const AI_ADMIN_SCRIPT: &str = r#" var items = mcpServers.map(function(s, idx) { var disabledAttr = isAdmin ? '' : ' disabled'; var secretRefsStr = Array.isArray(s.secretRefs) ? s.secretRefs.join(', ') : (s.secretRefs || ''); - return '
    ' + - '
    ' + + return '
    ' + + '' + '' + escapeHtml(s.name || s.id || 'MCP ' + (idx + 1)) + '' + '' + renderTag(s.transport || 'stdio', 'blue') + renderTag(s.facadeOnly !== false ? 'facade-only' : 'raw', s.facadeOnly !== false ? 'green' : 'orange') + '' + (isAdmin ? '' : '') + - '
    ' + + '
    ' + '
    ' + '
    ' + '
    Facade-only(不暴露 raw MCP 给 AI)' + '' + '
    ' + - '
    '; + '
    '; }).join(''); mcpContainer.innerHTML = '
    ' + items; @@ -1854,6 +2345,7 @@ const AI_ADMIN_SCRIPT: &str = r#" }); }); } + keepSummaryControlsInteractive(mcpContainer); var mcpFilter = mcpContainer.querySelector('[data-ai-admin-filter="mcp"]'); if (mcpFilter) { mcpFilter.addEventListener('input', function() { @@ -1865,6 +2357,73 @@ const AI_ADMIN_SCRIPT: &str = r#" } } + function renderPiExtensionsConfig(piExtensions) { + if (!piExtensionsContainer) return; + if (!piExtensions || !piExtensions.length) { + piExtensionsContainer.innerHTML = '
    暂未配置 Pi 扩展包
    '; + return; + } + var enabledCount = piExtensions.filter(function(extension) { return extension.enabled !== false; }).length; + var items = piExtensions.map(function(extension, idx) { + var disabledAttr = isAdmin ? '' : ' disabled'; + var toolNames = Array.isArray(extension.toolNames) ? extension.toolNames.join(', ') : (extension.toolNames || ''); + var requiredScopes = Array.isArray(extension.requiredScopes) ? extension.requiredScopes.join(', ') : (extension.requiredScopes || ''); + return '
    ' + + '' + + '' + escapeHtml(extension.name || extension.id || 'Pi Extension ' + (idx + 1)) + '' + + '' + + renderTag(extension.riskLevel || 'medium', (extension.riskLevel || '') === 'high' ? 'red' : 'orange') + + renderTag(toolNames ? ('tools ' + toolNames.split(',').filter(Boolean).length) : 'no tools', 'blue') + + '' + + (isAdmin ? '' : '') + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    Pi package 会执行代码,新增来源需要管理员先审核。
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    '; + }).join(''); + piExtensionsContainer.innerHTML = + '
    ✓ 已启用:' + enabledCount + '/' + piExtensions.length + '
    ' + + '
    ' + + items; + + if (isAdmin) { + piExtensionsContainer.querySelectorAll('[data-action="delete-pi-extension"]').forEach(function(btn) { + btn.addEventListener('click', function() { + var idx = parseInt(btn.getAttribute('data-idx'), 10); + if (!isNaN(idx) && configData.piExtensions && configData.piExtensions.length > idx) { + configData.piExtensions.splice(idx, 1); + renderPiExtensionsConfig(configData.piExtensions); + } + }); + }); + } + keepSummaryControlsInteractive(piExtensionsContainer); + var piExtensionFilter = piExtensionsContainer.querySelector('[data-ai-admin-filter="pi-extensions"]'); + if (piExtensionFilter) { + piExtensionFilter.addEventListener('input', function() { + var q = piExtensionFilter.value.trim().toLowerCase(); + piExtensionsContainer.querySelectorAll('[data-pi-extension-idx]').forEach(function(card) { + card.style.display = !q || card.textContent.toLowerCase().includes(q) ? '' : 'none'; + }); + }); + } + } + function collectProvidersFromDOM() { if (!modelsContainer) return []; var cards = modelsContainer.querySelectorAll('.mnote-ai-admin-provider-card'); @@ -1953,6 +2512,34 @@ const AI_ADMIN_SCRIPT: &str = r#" return mcps.filter(Boolean); } + function collectPiExtensionsFromDOM() { + if (!piExtensionsContainer) return []; + var cards = piExtensionsContainer.querySelectorAll('[data-pi-extension-idx]'); + var extensions = []; + cards.forEach(function(card) { + var idx = parseInt(card.getAttribute('data-pi-extension-idx'), 10); + if (isNaN(idx) || !configData.piExtensions || !configData.piExtensions[idx]) return; + function val(field) { + var el = card.querySelector('[data-field="' + field + '"][data-idx="' + idx + '"]'); + if (!el) return ''; + if (el.type === 'checkbox') return el.checked; + return el.value; + } + var toolNamesRaw = val('pi-extension-tools'); + var scopesRaw = val('pi-extension-scopes'); + extensions[idx] = JSON.parse(JSON.stringify(configData.piExtensions[idx])); + extensions[idx].id = val('pi-extension-id'); + extensions[idx].name = val('pi-extension-name'); + extensions[idx].enabled = val('pi-extension-enabled'); + extensions[idx].description = val('pi-extension-description'); + extensions[idx].source = val('pi-extension-source'); + extensions[idx].toolNames = toolNamesRaw ? toolNamesRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : []; + extensions[idx].riskLevel = val('pi-extension-risk') || 'medium'; + extensions[idx].requiredScopes = scopesRaw ? scopesRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : []; + }); + return extensions.filter(Boolean); + } + // ── 添加空条目 ── function addEmptyProvider() { if (!configData.providers) configData.providers = []; @@ -1981,30 +2568,56 @@ const AI_ADMIN_SCRIPT: &str = r#" renderMCPConfig(configData.mcpServers); } + function addEmptyPiExtension() { + if (!configData.piExtensions) configData.piExtensions = []; + configData.piExtensions.push({ + id: '', + name: '', + description: '', + source: 'npm:', + toolNames: [], + riskLevel: 'medium', + requiredScopes: [], + enabled: true + }); + renderPiExtensionsConfig(configData.piExtensions); + } + // ── 加载 config ── async function loadConfig() { var payload; try { - payload = await requestJson('/api/ai-admin/settings', { method: 'GET' }); + payload = await requestJson(isAdmin ? '/api/ai-admin/settings' : '/api/ai-settings/effective', { method: 'GET' }); } catch (err) { if (modelsContainer) modelsContainer.innerHTML = '
    模型配置 API 暂不可用
    '; if (toolsContainer) toolsContainer.innerHTML = '
    工具配置 API 暂不可用
    '; if (skillsContainer) skillsContainer.innerHTML = '
    技能配置 API 暂不可用
    '; if (mcpContainer) mcpContainer.innerHTML = '
    MCP 配置 API 暂不可用
    '; + if (piExtensionsContainer) piExtensionsContainer.innerHTML = '
    Pi 扩展包配置 API 暂不可用
    '; return; } - configData.providers = objectValuesWithId(payload.providers); - configData.toolPolicies = (payload.toolCatalog || []).map(function(tool) { - return { ...tool, defaultPolicy: (payload.tools || {})[tool.name] || tool.defaultPolicy }; - }); - configData.skills = objectValuesWithId(payload.skills); - configData.mcpServers = objectValuesWithId(payload.mcpServers); + if (isAdmin) { + configData.providers = objectValuesWithId(payload.providers); + configData.toolPolicies = (payload.toolCatalog || []).map(function(tool) { + return { ...tool, defaultPolicy: (payload.tools || {})[tool.name] || tool.defaultPolicy }; + }); + configData.skills = objectValuesWithId(payload.skills); + configData.mcpServers = objectValuesWithId(payload.mcpServers); + configData.piExtensions = objectValuesWithId(payload.piExtensions); + } else { + configData.providers = Array.isArray(payload.providers) ? payload.providers : []; + configData.toolPolicies = Array.isArray(payload.toolCatalog || payload.tool_catalog) ? (payload.toolCatalog || payload.tool_catalog) : []; + configData.skills = Array.isArray(payload.skills) ? payload.skills : []; + configData.mcpServers = Array.isArray(payload.mcpServers || payload.mcp_servers) ? (payload.mcpServers || payload.mcp_servers) : []; + configData.piExtensions = Array.isArray(payload.piExtensions || payload.pi_extensions) ? (payload.piExtensions || payload.pi_extensions) : []; + } renderProviderConfig(configData.providers); renderToolsConfig(configData.toolPolicies); renderSkillsConfig(configData.skills); renderMCPConfig(configData.mcpServers); + renderPiExtensionsConfig(configData.piExtensions); } // ── 保存 config ── @@ -2055,6 +2668,8 @@ const AI_ADMIN_SCRIPT: &str = r#" description: skill.description || '' }; }); + } + if (scope === 'mcp' || !scope) { body.mcpServers = {}; collectMCPFromDOM().forEach(function(server) { var id = server.id || server.name; @@ -2072,6 +2687,22 @@ const AI_ADMIN_SCRIPT: &str = r#" }; }); } + if (scope === 'pi-extensions' || !scope) { + body.piExtensions = {}; + collectPiExtensionsFromDOM().forEach(function(extension) { + var id = extension.id || extension.name; + if (!id) return; + body.piExtensions[id] = { + name: extension.name, + enabled: extension.enabled, + description: extension.description || '', + source: extension.source || '', + toolNames: extension.toolNames || [], + riskLevel: extension.riskLevel || 'medium', + requiredScopes: extension.requiredScopes || [] + }; + }); + } try { await requestJson('/api/ai-admin/settings', { @@ -2084,7 +2715,11 @@ const AI_ADMIN_SCRIPT: &str = r#" } else if (scope === 'tools') { showSaveStatus(toolsSaveStatus, 'success', '工具策略已保存'); } else if (scope === 'skills') { - showSaveStatus(skillsSaveStatus, 'success', '技能/MCP 配置已保存'); + showSaveStatus(skillsSaveStatus, 'success', 'Skills 配置已保存'); + } else if (scope === 'mcp') { + showSaveStatus(mcpSaveStatus, 'success', 'MCP 配置已保存'); + } else if (scope === 'pi-extensions') { + showSaveStatus(piExtensionsSaveStatus, 'success', 'Pi 扩展配置已保存'); } } catch (err) { if (scope === 'models') { @@ -2093,6 +2728,10 @@ const AI_ADMIN_SCRIPT: &str = r#" showSaveStatus(toolsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误')); } else if (scope === 'skills') { showSaveStatus(skillsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误')); + } else if (scope === 'mcp') { + showSaveStatus(mcpSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误')); + } else if (scope === 'pi-extensions') { + showSaveStatus(piExtensionsSaveStatus, 'error', '保存失败: ' + (err.message || '未知错误')); } } } @@ -2111,6 +2750,14 @@ const AI_ADMIN_SCRIPT: &str = r#" if (saveSkillsBtn) { saveSkillsBtn.addEventListener('click', function () { saveConfig('skills'); }); } + var saveMCPBtn = root.querySelector('[data-action="save-mcp"]'); + if (saveMCPBtn) { + saveMCPBtn.addEventListener('click', function () { saveConfig('mcp'); }); + } + var savePiExtensionsBtn = root.querySelector('[data-action="save-pi-extensions"]'); + if (savePiExtensionsBtn) { + savePiExtensionsBtn.addEventListener('click', function () { saveConfig('pi-extensions'); }); + } var addProviderBtn = root.querySelector('[data-action="add-provider"]'); if (addProviderBtn) { addProviderBtn.addEventListener('click', addEmptyProvider); @@ -2123,6 +2770,10 @@ const AI_ADMIN_SCRIPT: &str = r#" if (addMCPBtn) { addMCPBtn.addEventListener('click', addEmptyMCP); } + var addPiExtensionBtn = root.querySelector('[data-action="add-pi-extension"]'); + if (addPiExtensionBtn) { + addPiExtensionBtn.addEventListener('click', addEmptyPiExtension); + } } // ── 启动 ── @@ -2132,6 +2783,9 @@ const AI_ADMIN_SCRIPT: &str = r#" loadAccessScopes().catch(function (err) { if (accessScopesMessage) setText(accessScopesMessage, err.message || '加载失败'); }); + loadDirectoryAccessRequests().catch(function (err) { + if (accessRequestsList) accessRequestsList.innerHTML = '
    ' + escapeHtml(err.message || '加载权限申请失败') + '
    '; + }); loadSessionsAndReceipts().catch(function (err) { if (receiptsSummary) setText(receiptsSummary, err.message || '加载会话与回执失败'); }); @@ -2139,9 +2793,11 @@ const AI_ADMIN_SCRIPT: &str = r#" // 静默处理,各个容器已显示错误状态 console.warn('loadConfig 失败:', err && err.message); }); - loadUsers().catch(function (err) { - if (userSettingsEditor) userSettingsEditor.innerHTML = '
    ' + escapeHtml(err.message || '加载用户失败') + '
    '; - }); + if (isAdmin) { + loadUsers().catch(function (err) { + if (userSettingsEditor) userSettingsEditor.innerHTML = '
    ' + escapeHtml(err.message || '加载用户失败') + '
    '; + }); + } } window.MNOTEInitAiAdminPage = initAiAdminPage; @@ -2169,21 +2825,9 @@ pub fn AiManagementPage( .unwrap_or_else(|| "工作区".to_string()); let role_text = if is_admin { - "管理员模式 — 可管理 AI Providers 和访问策略" + "管理员设置 — 可管理 AI Providers、目录权限和审批" } else { - "用户模式 — 查看 AI 配置与已授权文件夹" - }; - - let access_policy_href = if is_admin { - "/admin/access-policy" - } else { - "/user/access-policy" - }; - - let access_policy_label = if is_admin { - "前往管理员授权管理" - } else { - "查看我的文件夹授权" + "个人设置 — 查看 AI 配置、当前目录权限并提交申请" }; let config_json = format!( @@ -2210,22 +2854,42 @@ pub fn AiManagementPage(