feat: integrate pi rust lab runtime

This commit is contained in:
Agent Board
2026-07-10 10:54:34 +08:00
parent c8472bb898
commit 896c94b696
73 changed files with 19852 additions and 1152 deletions
+2
View File
@@ -27,6 +27,8 @@ env-archive/
/.gemini/
/.reasonix/
/.codegraph/
/.pi/
/tmp-block-handle-qa.js
*.local
tmp/
@@ -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 fallbackfallback CSS 只对带 `data-icon` 的节点生效;缺少 `data-icon` 时,浏览器没有 Material Symbols 字体可用,就直接把 ligature 文本当普通文本显示出来。
## 修复
- `material_icon()` 改为输出 `data-icon=<name>`,并移除可见 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` 的历史图标节点。
@@ -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 headlessviewport 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 errorsSSE 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 页)
@@ -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.
@@ -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: 2hover 手柄出现 + 点击后菜单弹出)
- 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.
@@ -0,0 +1,40 @@
# 7-73 Page AI Pi UI 产品化收口 v1
状态:done
Owner07-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`
@@ -0,0 +1,378 @@
# 7-72 Page AI Pi UI 补全方案 v1
状态:process
Owner07-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 是 steerAlt+Enter 是 follow-upEscape 是 abortAlt+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 railLightRAG、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
- Artifactsdiff、citation、open-reference、文件变更
- DiagnosticsRPC 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:<server>/<tool>`,例如 `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 modeMNote 侧展示 MNote 实际 tool policy / directory grant。
- [x] 后端 `PiLabStartRequest` / `PiLabSession` 增加 `thinkingLevel`,真实 Pi RPC 启动时传 `--thinking <level>`,并写入 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 2Streaming 中断与追加回复
目标:补齐 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 时输入一条 steerUI 显示 queued/steering。
- follow-up 在当前 turn 完成后自动执行。
- Escape 或 Stop 后 assistant message 显示 aborted 状态,不把“已中止”混进正常 Markdown 正文。
### Phase 3Extension 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 4Artifact / 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 5History / 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
@@ -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<string, unknown>) => Promise<boolean>;
};
};
type ExtensionAPI = {
registerTool: (spec: Record<string, unknown>) => void;
on?: (
eventName: string,
handler: (event: Record<string, unknown>) => Record<string, unknown> | 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<string, unknown> | 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<string, unknown>): Record<string, unknown> {
const parsed = parseJsonUnknown(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: 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<string, unknown>;
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<string, unknown>, key: string): string {
const value = record[key];
return typeof value === "string" ? value.trim() : "";
}
function toolResult(payload: Record<string, unknown>, isError = false) {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
isError,
};
}
function decodeHexJson(value: string): Record<string, unknown> {
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<string, unknown>;
}
function captureInputContext(event: Record<string, unknown>) {
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<string, unknown> {
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<string, unknown>;
}
function contextAllowedRoots(context: Record<string, unknown>): Record<string, unknown>[] {
const snapshot = context.allowedRoots;
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return [];
const roots = (snapshot as Record<string, unknown>).roots;
if (!Array.isArray(roots)) return [];
return roots.filter((root): root is Record<string, unknown> => (
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<string, unknown>,
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<string, unknown>
: {};
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<string, unknown>).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<string, unknown>;
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<string, unknown>) };
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<string, unknown>)[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<string, unknown>): Promise<Record<string, unknown>> {
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<null | Record<string, unknown>> {
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<string, unknown>) };
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);
}
@@ -0,0 +1,9 @@
{
"mcpServers": {
"example-filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"disabled": true
}
}
}
@@ -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 打印 JSONstderr 仅用于诊断
*
* 限制:不含 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|-> | --request-json '<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);
});
@@ -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<string, unknown>) => void;
}
interface ToolResult {
content?: Array<{ type: string; text?: string; [key: string]: unknown }>;
details?: Record<string, unknown>;
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<string, unknown>;
}): Record<string, unknown> {
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<string, unknown>;
} 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<string, unknown> },
): Promise<ToolResult> {
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,
};
}
},
});
}
@@ -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;
});
}
@@ -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`
@@ -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);
});
}
@@ -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;
}
@@ -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);
},
});
}
@@ -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<QuestionnaireResult>((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<string, Answer>();
// 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);
},
});
}
@@ -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 <query>` | scout → planner → worker |
| `/scout-and-plan <query>` | scout → planner |
| `/implement-and-review <query>` | 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
@@ -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<Record<string, string>>(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<string, AgentConfig>();
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,
};
}
@@ -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.
@@ -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.
@@ -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.
@@ -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)
@@ -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<string, unknown>,
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<string, any> };
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<TIn, TOut>(
items: TIn[],
concurrency: number,
fn: (item: TIn, index: number) => Promise<TOut>,
): Promise<TOut[]> {
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<SubagentDetails>) => 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<SingleResult> {
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<number>((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<string>();
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);
},
});
}
@@ -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}.
@@ -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}.
@@ -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.
@@ -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<void>((_tui, theme, _kb, done) => {
return new TodoListComponent(todos, theme, () => done());
});
},
});
}
+25
View File
@@ -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"
]
}
}
+3
View File
@@ -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.
+12
View File
@@ -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`.
File diff suppressed because it is too large Load Diff
@@ -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 =
'<div class="mnote-admin-policy-modal__backdrop" data-admin-access-policy-close></div>' +
'<section class="mnote-admin-policy-modal__panel">' +
'<button type="button" class="mnote-admin-policy-modal__close" data-admin-access-policy-close aria-label="关闭授权管理"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
'<div class="mnote-admin-policy-modal__content" data-testid="mnote-admin-access-policy-modal-content"></div>' +
'</section>';
var content = dialog.querySelector('[data-testid="mnote-admin-access-policy-modal-content"]');
if (content) {
content.innerHTML = template ? template.innerHTML : '<div class="mnote-admin-policy-empty">授权管理面板加载失败</div>';
}
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 =
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-profile" role="menuitem"><span class="material-symbols-outlined" data-icon="account_circle" aria-hidden="true"></span><span>个人信息</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-access-policy" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="admin_panel_settings" aria-hidden="true"></span><span>授权管理</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-ai-management" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="smart_toy" aria-hidden="true"></span><span>AI 管理</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__item" data-testid="mnote-account-ai-management" role="menuitem" hidden><span class="material-symbols-outlined" data-icon="auto_awesome" aria-hidden="true"></span><span>AI 管理</span></button>' +
'<button type="button" class="mnote-workspace-source-menu__item mnote-account-menu__logout" data-testid="mnote-account-sign-out" role="menuitem">退出登录</button>' +
'<div class="mnote-account-menu__error" data-account-error hidden></div>';
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;
File diff suppressed because it is too large Load Diff
+119 -1
View File
@@ -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<DevSeedRuntimeEvent>,
},
SeedAiPolicy {
#[serde(default)]
id: Option<String>,
#[serde(default)]
#[serde(alias = "userId")]
user_id: Option<String>,
#[serde(default)]
#[serde(alias = "workspaceId")]
workspace_id: Option<String>,
#[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);
}
}
+51 -78
View File
@@ -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! {
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} share_grants_path={share_grants_path} is_admin=true />
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title></title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="admin" data-mnote-actor-id="{}">
{}
</body>
</html>"#,
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! {
<crate::ssr::pages::admin::AdminAccessPolicyPanel workspace_name={workspace_name} is_admin=false />
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title></title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="user-access-policy" data-mnote-actor-id="{}">
{}
</body>
</html>"#,
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<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&state, &context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
ai_management_response(
&state,
&context,
is_local_access_policy_admin_context(&context),
)
}
pub async fn user_ai_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -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]
@@ -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),
})),
@@ -56,6 +56,11 @@ static LOCAL_PAGE_TREE_SNAPSHOT_CACHE: OnceLock<
Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>>,
> = OnceLock::new();
#[cfg(not(test))]
static LOCAL_SEARCH_INDEX_CONTROL_PLANE: OnceLock<
std::sync::Arc<dyn control_plane::ControlPlaneStore>,
> = OnceLock::new();
fn local_page_tree_snapshot_cache(
) -> &'static Mutex<BTreeMap<String, LocalPageTreeSnapshotCacheEntry>> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
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<Value, WebError> {
fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
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<Value, We
}))
}
fn idempotent_deleted_local_markdown_page(
root: &Path,
metadata: &LocalFolderMetadata,
document_id: &str,
) -> Result<Option<Value>, 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<Option<Value>, 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<Option<Value>, 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<Value, WebError> {
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<Valu
}))
}
fn idempotent_deleted_local_raw_file(
root: &Path,
entry_id: &str,
) -> Result<Option<Value>, 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<Value, WebError> {
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<Value, WebError> {
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<Value,
}))
}
fn idempotent_restored_local_markdown_page(
root: &Path,
document_id: &str,
) -> Result<Option<Value>, 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<Value, WebError
}))
}
fn idempotent_restored_local_raw_file(
root: &Path,
entry_id: &str,
) -> Result<Option<Value>, 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<Value, WebError> {
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<Value, WebErro
}))
}
fn idempotent_deleted_local_directory(
root: &Path,
entry_id: &str,
) -> Result<Option<Value>, 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<String> {
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<String> {
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<Option<Value>, 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<Option<Value>, 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<Value, WebError> {
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<Value, We
"sourceKind": "local_folder",
}));
}
let trash_entry = metadata.trash_entries.remove(document_id).ok_or_else(|| {
WebError::bad_request_code(
"local_markdown_not_found",
"找不到要永久删除的本地 Markdown 页面",
)
})?;
let trash_entry = match metadata.trash_entries.remove(document_id) {
Some(trash_entry) => 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<Value, WebError>
}))
}
fn idempotent_purged_local_raw_file(
root: &Path,
entry_id: &str,
) -> Result<Option<Value>, 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<Value, WebError> {
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");
+34 -2
View File
@@ -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),
File diff suppressed because it is too large Load Diff
+64
View File
@@ -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!(
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,5 @@
//! MNOTE 通用页面布局组件(Wolai / Notion 风格)
use crate::ssr::pages::admin::AdminAccessPolicyPanel;
use leptos::prelude::*;
const SIDEBAR_TREE_JS: &str = r##"
@@ -112,13 +111,6 @@ pub fn PageLayout(
"views": ["page-tree", "file-tree"]
})
.to_string();
let admin_access_policy_template = crate::ssr::render_view(leptos::view! {
<AdminAccessPolicyPanel workspace_name={ws_name.clone()} is_admin=true boot_script=false />
});
let user_access_policy_template = crate::ssr::render_view(leptos::view! {
<AdminAccessPolicyPanel workspace_name={ws_name.clone()} is_admin=false boot_script=false />
});
view! {
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
<aside class="mnote-sidebar wolai-sidebar" data-testid="wolai-sidebar">
@@ -160,9 +152,6 @@ pub fn PageLayout(
</nav>
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
<script id="__MNOTE_TREE_LIVE_BOOTSTRAP__" type="application/json" inner_html={tree_live_bootstrap}></script>
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
<script type="module" src={browser_runtime_src("mnote-ui-runtime.js")}></script>
<script type="module" src={browser_runtime_src("resource-open-runtime.js")}></script>
<script type="module" src={browser_runtime_src("local-upload-runtime.js")}></script>
+98 -1
View File
@@ -102,6 +102,7 @@ a:hover {
.material-symbols-outlined[data-icon="delete"]::before { content: "⌫"; }
.material-symbols-outlined[data-icon="right_panel_open"]::before,
.material-symbols-outlined[data-icon="open_in_new"]::before { content: "↗"; }
.material-symbols-outlined[data-icon="arrow_outward"]::before { content: "↗"; }
.material-symbols-outlined[data-icon="share"]::before { content: "⇪"; }
.material-symbols-outlined[data-icon="link"]::before { content: "∞"; }
.material-symbols-outlined[data-icon="content_copy"]::before,
@@ -109,6 +110,29 @@ a:hover {
.material-symbols-outlined[data-icon="tag"]::before { content: "#"; }
.material-symbols-outlined[data-icon="edit"]::before { content: "✎"; }
.material-symbols-outlined[data-icon="add"]::before { content: "+"; }
.material-symbols-outlined[data-icon="autorenew"]::before,
.material-symbols-outlined[data-icon="sync"]::before { content: "↻"; }
.material-symbols-outlined[data-icon="chevron_right"]::before { content: ""; }
.material-symbols-outlined[data-icon="chevron_left"]::before { content: ""; }
.material-symbols-outlined[data-icon="chat"]::before,
.material-symbols-outlined[data-icon="comment"]::before,
.material-symbols-outlined[data-icon="mode_comment"]::before { content: "▢"; }
.material-symbols-outlined[data-icon="palette"]::before { content: "◐"; }
.material-symbols-outlined[data-icon="format_align_center"]::before { content: "≡"; }
.material-symbols-outlined[data-icon="translate"]::before { content: "文"; }
.material-symbols-outlined[data-icon="dashboard"]::before { content: "▦"; }
.material-symbols-outlined[data-icon="description"]::before,
.material-symbols-outlined[data-icon="article"]::before,
.material-symbols-outlined[data-icon="notes"]::before { content: "▤"; }
.material-symbols-outlined[data-icon="format_list_bulleted"]::before { content: "☷"; }
.material-symbols-outlined[data-icon="checklist"]::before { content: "☑"; }
.material-symbols-outlined[data-icon="text_fields"]::before { content: "T"; }
.material-symbols-outlined[data-icon="function"]::before { content: "ƒ"; }
.material-symbols-outlined[data-icon="looks_one"]::before { content: "1"; }
.material-symbols-outlined[data-icon="looks_two"]::before { content: "2"; }
.material-symbols-outlined[data-icon="looks_3"]::before { content: "3"; }
.material-symbols-outlined[data-icon="looks_4"]::before { content: "4"; }
.material-symbols-outlined[data-icon="check"]::before { content: "✓"; }
.material-symbols-outlined[data-icon="manage_search"]::before { content: "⌕"; }
.material-symbols-outlined[data-icon="subdirectory_arrow_right"]::before { content: "↳"; }
@@ -134,19 +158,26 @@ a:hover {
.material-symbols-outlined[data-icon="drive_file_move"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 6h6l2 2h8v10H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m13 12 3 3-3 3M8 15h8' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="delete"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 6h14M9 6V4h6v2M8 6l1 14h6l1-14M10.5 10v6M13.5 10v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="right_panel_open"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v14H4zM14 5v14M8 12h6M11 9l3 3-3 3' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="autorenew"],
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M19 8a7 7 0 0 0-11.8-2.8L5 7.5M5 4v3.5h3.5M5 16a7 7 0 0 0 11.8 2.8L19 16.5M19 20v-3.5h-3.5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="chevron_right"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m9 5 7 7-7 7' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="chevron_left"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m15 5-7 7 7 7' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="preview"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Ccircle cx='12' cy='12' r='2.8' fill='none' stroke='black' stroke-width='2'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="download"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 4v10M8.5 11.5 12 15l3.5-3.5M5 19h14' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="notes"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 5h12v14H6zM9 9h6M9 12h6M9 15h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="arrow_outward"],
.material-symbols-outlined[data-icon="open_in_new"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 6H5v13h13v-3M12 5h7v7M10 14 19 5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="share"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='18' cy='5' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='6' cy='12' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='18' cy='19' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='m8.7 10.7 6.6-4.4M8.7 13.3l6.6 4.4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="chat"],
.material-symbols-outlined[data-icon="mode_comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="travel_explore"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='11' cy='11' r='7' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M4 11h14M11 4a10 10 0 0 1 0 14M11 4a10 10 0 0 0 0 14M16.5 16.5 21 21' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M7.5 8.5h2.5l1 1.5 2.5-.5 1 2-2 1 .5 2.5h-3l-1.5-2-2 .5' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="document_scanner"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7 3H5a2 2 0 0 0-2 2v2M17 3h2a2 2 0 0 1 2 2v2M7 21H5a2 2 0 0 1-2-2v-2M17 21h2a2 2 0 0 0 2-2v-2M7 8h10M7 12h10M7 16h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="article"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="article"],
.material-symbols-outlined[data-icon="description"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 9A6 6 0 0 1 18 6M16.5 15A6 6 0 0 1 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="drive_file_rename_outline"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m4 17-.5 3.5L7 20l11-11-3-3zM13 8l3 3M5 6h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="format_paint"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 7h10l2 3-2 3H5l-2-3zM9 13v6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
@@ -160,6 +191,72 @@ a:hover {
.material-symbols-outlined[data-icon="account_circle"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='12' cy='12' r='9' fill='none' stroke='black' stroke-width='2'/%3E%3Ccircle cx='12' cy='9' r='3' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M6.8 19a5.4 5.4 0 0 1 10.4 0' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="admin_panel_settings"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 3 5 6v5c0 4.5 2.9 8 7 10 4.1-2 7-5.5 7-10V6z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m9 12 2 2 4-5' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="close"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 6 18 18M18 6 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="backspace"]::before,
.material-symbols-outlined[data-icon="check"]::before,
.material-symbols-outlined[data-icon="checklist"]::before,
.material-symbols-outlined[data-icon="code"]::before,
.material-symbols-outlined[data-icon="dashboard"]::before,
.material-symbols-outlined[data-icon="format_align_center"]::before,
.material-symbols-outlined[data-icon="format_bold"]::before,
.material-symbols-outlined[data-icon="format_list_bulleted"]::before,
.material-symbols-outlined[data-icon="format_list_numbered"]::before,
.material-symbols-outlined[data-icon="format_quote"]::before,
.material-symbols-outlined[data-icon="function"]::before,
.material-symbols-outlined[data-icon="horizontal_rule"]::before,
.material-symbols-outlined[data-icon="image"]::before,
.material-symbols-outlined[data-icon="looks_one"]::before,
.material-symbols-outlined[data-icon="looks_two"]::before,
.material-symbols-outlined[data-icon="looks_3"]::before,
.material-symbols-outlined[data-icon="looks_4"]::before,
.material-symbols-outlined[data-icon="lock"]::before,
.material-symbols-outlined[data-icon="lock_open"]::before,
.material-symbols-outlined[data-icon="palette"]::before,
.material-symbols-outlined[data-icon="playlist_add"]::before,
.material-symbols-outlined[data-icon="redo"]::before,
.material-symbols-outlined[data-icon="remove"]::before,
.material-symbols-outlined[data-icon="settings"]::before,
.material-symbols-outlined[data-icon="table_chart"]::before,
.material-symbols-outlined[data-icon="text_fields"]::before,
.material-symbols-outlined[data-icon="toc"]::before,
.material-symbols-outlined[data-icon="translate"]::before,
.material-symbols-outlined[data-icon="upload_file"]::before,
.material-symbols-outlined[data-icon="view_column"]::before {
width: auto;
height: auto;
background: transparent;
-webkit-mask: none;
mask: none;
}
.material-symbols-outlined[data-icon="backspace"]::before { content: "⌫"; }
.material-symbols-outlined[data-icon="check"]::before { content: "✓"; }
.material-symbols-outlined[data-icon="checklist"]::before { content: "☑"; }
.material-symbols-outlined[data-icon="code"]::before { content: "{}"; }
.material-symbols-outlined[data-icon="dashboard"]::before { content: "▦"; }
.material-symbols-outlined[data-icon="format_align_center"]::before { content: "≡"; }
.material-symbols-outlined[data-icon="format_bold"]::before { content: "B"; }
.material-symbols-outlined[data-icon="format_list_bulleted"]::before { content: "☷"; }
.material-symbols-outlined[data-icon="format_list_numbered"]::before { content: "1."; }
.material-symbols-outlined[data-icon="format_quote"]::before { content: "“"; }
.material-symbols-outlined[data-icon="function"]::before { content: "ƒ"; }
.material-symbols-outlined[data-icon="horizontal_rule"]::before { content: "─"; }
.material-symbols-outlined[data-icon="image"]::before { content: "▧"; }
.material-symbols-outlined[data-icon="looks_one"]::before { content: "1"; }
.material-symbols-outlined[data-icon="looks_two"]::before { content: "2"; }
.material-symbols-outlined[data-icon="looks_3"]::before { content: "3"; }
.material-symbols-outlined[data-icon="looks_4"]::before { content: "4"; }
.material-symbols-outlined[data-icon="lock"]::before { content: "▣"; }
.material-symbols-outlined[data-icon="lock_open"]::before { content: "▢"; }
.material-symbols-outlined[data-icon="palette"]::before { content: "◐"; }
.material-symbols-outlined[data-icon="playlist_add"]::before { content: "☰+"; }
.material-symbols-outlined[data-icon="redo"]::before { content: "↷"; }
.material-symbols-outlined[data-icon="remove"]::before { content: ""; }
.material-symbols-outlined[data-icon="settings"]::before { content: "⚙"; }
.material-symbols-outlined[data-icon="table_chart"]::before { content: "▦"; }
.material-symbols-outlined[data-icon="text_fields"]::before { content: "T"; }
.material-symbols-outlined[data-icon="toc"]::before { content: "☰"; }
.material-symbols-outlined[data-icon="translate"]::before { content: "文"; }
.material-symbols-outlined[data-icon="upload_file"]::before { content: "⇧"; }
.material-symbols-outlined[data-icon="view_column"]::before { content: "▥"; }
.material-symbols-outlined[data-icon="radio_button_unchecked"]::before {
width: .78em;
height: .78em;
@@ -48,33 +48,31 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly mount: (a: any, b: any) => [number, number, number];
readonly mount_mindmap_shell: (a: any, b: any) => [number, number, number];
readonly unmount: (a: number) => [number, number];
readonly unmount_mindmap_shell: (a: number) => [number, number];
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
readonly intounderlyingbytesource_cancel: (a: number) => void;
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
readonly intounderlyingbytesource_type: (a: number) => number;
readonly mount: (a: any, b: any) => [number, number, number];
readonly unmount: (a: number) => [number, number];
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
readonly intounderlyingsink_abort: (a: number, b: any) => any;
readonly intounderlyingsink_close: (a: number) => any;
readonly intounderlyingsink_write: (a: number, b: any) => any;
readonly intounderlyingsink_close: (a: number) => any;
readonly intounderlyingsink_abort: (a: number, b: any) => any;
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
readonly intounderlyingbytesource_type: (a: number) => number;
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
readonly intounderlyingbytesource_cancel: (a: number) => void;
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
readonly intounderlyingsource_cancel: (a: number) => void;
readonly intounderlyingsource_pull: (a: number, b: any) => any;
readonly wasm_bindgen__convert__closures_____invoke__h854f4676fa692669: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252: (a: number, b: number) => void;
readonly intounderlyingsource_cancel: (a: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_alloc: () => number;
@@ -812,7 +812,7 @@ function __wbg_get_imports() {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(a, state0.b, arg0, arg1);
return wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
@@ -1232,66 +1232,56 @@ function __wbg_get_imports() {
}
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1011, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1139, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1140, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1191, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1202, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h854f4676fa692669);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 993, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1110, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1139, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1140, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 701, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 563, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1108, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1108, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1142, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1125, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1143, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0) {
__wbindgen_cast_0000000000000008: function(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0) {
__wbindgen_cast_0000000000000009: function(arg0) {
// Cast intrinsic for `I64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_000000000000000d: function(arg0) {
__wbindgen_cast_000000000000000b: function(arg0) {
// Cast intrinsic for `U64 -> Externref`.
const ret = BigInt.asUintN(64, arg0);
return ret;
@@ -1316,47 +1306,39 @@ function __wbg_get_imports() {
};
}
function wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__h854f4676fa692669(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h854f4676fa692669(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350(arg0, arg1, arg2, arg3);
function wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3);
}
@@ -1,33 +1,31 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const mount: (a: any, b: any) => [number, number, number];
export const mount_mindmap_shell: (a: any, b: any) => [number, number, number];
export const unmount: (a: number) => [number, number];
export const unmount_mindmap_shell: (a: number) => [number, number];
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
export const intounderlyingbytesource_cancel: (a: number) => void;
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
export const intounderlyingbytesource_start: (a: number, b: any) => void;
export const intounderlyingbytesource_type: (a: number) => number;
export const mount: (a: any, b: any) => [number, number, number];
export const unmount: (a: number) => [number, number];
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
export const intounderlyingsink_abort: (a: number, b: any) => any;
export const intounderlyingsink_close: (a: number) => any;
export const intounderlyingsink_write: (a: number, b: any) => any;
export const intounderlyingsink_close: (a: number) => any;
export const intounderlyingsink_abort: (a: number, b: any) => any;
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
export const intounderlyingbytesource_type: (a: number) => number;
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
export const intounderlyingbytesource_start: (a: number, b: any) => void;
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
export const intounderlyingbytesource_cancel: (a: number) => void;
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
export const intounderlyingsource_cancel: (a: number) => void;
export const intounderlyingsource_pull: (a: number, b: any) => any;
export const wasm_bindgen__convert__closures_____invoke__h854f4676fa692669: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h1a94ffe1b1ed8350: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h8eb6fe4c74941e76: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h11eca4a91ce748ff: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h62d2a368ae4b53af_4: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__hc1d64d6259b30403: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__hdc3cd9a3517d1395: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h4fb07e4e902e51ce: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hec6d5749a9244252: (a: number, b: number) => void;
export const intounderlyingsource_cancel: (a: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h87a7c2758b5f97bf: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h43695889c03f47e9_3: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h79038d3430a7951a: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h67ea3224860feb45: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h29a925adc01712a5: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __externref_table_alloc: () => number;
@@ -1,7 +1,3 @@
use crate::{
current_page_anchor_url, editor_block_by_id, request_ai_edit_bridge,
write_mnote_text_to_clipboard,
};
use crate::editor_runtime::{
block_hover_state::{BlockMenuLayout, HoveredBlockState},
block_transform::{
@@ -17,6 +13,10 @@ use crate::editor_runtime::{
persistence::{persist_document_state, persisted_document_identity, PersistedDocumentIdentity},
slash_actions::{SlashActionKind, SLASH_ACTIONS},
};
use crate::{
current_page_anchor_url, editor_block_by_id, request_ai_edit_bridge,
write_mnote_text_to_clipboard,
};
use leptos::prelude::*;
use leptos_tiptap::{TiptapEditorHandle, TiptapSelectionState, TiptapTextAlign};
use serde_json::Value;
@@ -132,10 +132,9 @@ fn handle_turn_into_action_click(
&snapshot,
Some(html),
) {
Ok(()) => signals.set_command_feedback.set(format!(
"{}:块 -> {},并已写入本地草稿",
message, label,
)),
Ok(()) => signals
.set_command_feedback
.set(format!("{}:块 -> {},并已写入本地草稿", message, label,)),
Err(err) => signals.set_command_feedback.set(format!(
"{}:块 -> {},但本地保存失败:{}",
message, label, err,
@@ -245,7 +244,7 @@ fn block_transform_submenu_view(
handle_turn_into_action_click(editor, block_index, kind, label, signals);
}
>
<span class="block-drag-menu-icon material-symbols-outlined">{icon}</span>
<span class="block-drag-menu-icon material-symbols-outlined" data-icon=icon></span>
<span>{label}</span>
<span class="block-drag-menu-shortcut">{shortcut}</span>
</button>
@@ -331,7 +330,7 @@ fn block_transform_submenu_view(
handle_folded_heading_click(editor, block_index, action, signals);
}
>
<span class="block-drag-menu-icon material-symbols-outlined">{action.icon}</span>
<span class="block-drag-menu-icon material-symbols-outlined" data-icon=action.icon></span>
<span>{action.label}</span>
</button>
}
@@ -1,15 +1,16 @@
//! Unified icon helpers — Material Symbols ligatures for all editor runtime icons.
//!
//! Every icon rendered in the leptos-tiptap editor shell should flow through
//! `material_icon()` or consume `ICON_MAP` so that the set of Material Symbols
//! ligatures stays centralised and we can swap font providers later.
//! `material_icon()` or consume `ICON_MAP` so that icon names stay centralised
//! and the host stylesheet can render local fallback masks without leaking
//! Material Symbols ligature text.
use leptos::prelude::*;
/// Semantic name → Material Symbols ligature.
/// Semantic name → icon identifier.
///
/// Keys are stable semantic identifiers; values are the literal text content
/// that `material-symbols-outlined` will turn into a glyph via ligature.
/// Keys are stable semantic identifiers; values are the `data-icon` identifier
/// consumed by the host stylesheet.
pub(crate) const ICON_MAP: &[(&str, &str)] = &[
("heading_1", "looks_one"),
("heading_2", "looks_two"),
@@ -75,10 +76,10 @@ pub(crate) fn icon_ligature(name: &str) -> Option<&'static str> {
/// Render a `<span class="material-symbols-outlined">` icon.
///
/// The `name` parameter must be a Material Symbols ligature name
/// The `name` parameter must be a known icon identifier
/// (e.g. `"auto_awesome"`, `"chevron_right"`, `"looks_one"`).
pub(crate) fn material_icon(name: &'static str) -> impl IntoView {
view! {
<span class="material-symbols-outlined" aria-hidden="true">{name}</span>
<span class="material-symbols-outlined" data-icon=name aria-hidden="true"></span>
}
}
@@ -83,12 +83,8 @@ fn render_slash_shortcut(shortcut: &str) -> impl IntoView {
}
}
fn filtered_slash_intro_view(
query: &str,
command_feedback: WriteSignal<String>,
) -> impl IntoView {
let show_turn_into = !query.is_empty()
&& ("zhw".contains(query) || "转换为".contains(query));
fn filtered_slash_intro_view(query: &str, command_feedback: WriteSignal<String>) -> impl IntoView {
let show_turn_into = !query.is_empty() && ("zhw".contains(query) || "转换为".contains(query));
let show_page = !query.is_empty() && ("ym".contains(query) || "页面".contains(query));
if show_turn_into || show_page {
@@ -213,9 +209,7 @@ fn handle_slash_item_click(
}
Err(err) => {
signals.set_slash_open.set(false);
signals
.command_feedback
.set(format!("命令执行失败:{err}"));
signals.command_feedback.set(format!("命令执行失败:{err}"));
}
}
}
@@ -282,7 +276,7 @@ pub(crate) fn slash_menu_view(props: SlashMenuViewProps) -> impl IntoView {
}
>
<span class="slash-item-row">
<span class="slash-item-icon material-symbols-outlined">{icon}</span>
<span class="slash-item-icon material-symbols-outlined" data-icon=icon></span>
<span class="slash-item-copy">
<strong>{label}</strong>
<span>{description}</span>
+28
View File
@@ -21,6 +21,8 @@
* - MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE默认 "1"禁用 OpenHub Git snapshot/restore/revert 写链
* - MNOTE_CONTROL_PLANE_BACKEND控制面后端默认 libsql-local可设 turso-remote / turso-local-replica / turso-synced
* - MNOTE_TURSO_LOCAL_PATHlibsql-local 本地库路径默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
* - MNOTE_PAGE_AI_PI_WARMUP设为 "1" or "true" mnote-web 可用后预启动 Pi Lab runtime / MCP cache
* - MNOTE_PAGE_AI_PI_WARMUP_SEND设为 "1" or "true" 额外发送一次轻量 prompt 预热模型首包会产生真实模型请求
* - PYTHON_BIN只在 BACKEND_CMD 未覆盖时设置 Python 可执行文件默认 "python"
*/
@@ -28,6 +30,7 @@ const { spawn, execSync } = require("child_process");
const path = require("path");
const net = require("net");
const fs = require("fs");
const { runPiLabWarmup } = require("./lib/pi-lab-warmup");
const rootDir = path.resolve(__dirname, "..");
const backendDir = path.join(rootDir, "wolai-backend");
@@ -730,6 +733,29 @@ function startTask(task) {
children.push(child);
}
function schedulePiLabWarmup(frontendUrl) {
if (!isEnabledEnv(mergedEnv.MNOTE_PAGE_AI_PI_WARMUP)) return;
const warmupEnv = {
...mergedEnv,
MNOTE_PAGE_AI_PI_WARMUP_BASE_URL: mergedEnv.MNOTE_PAGE_AI_PI_WARMUP_BASE_URL || frontendUrl,
};
runPiLabWarmup(warmupEnv, {
rootDir,
log: (message) => logPrefix("pi-warmup", message),
}).then((result) => {
if (result.skipped) {
logPrefix("pi-warmup", `已跳过:${result.reason || "disabled"}`);
return;
}
logPrefix(
"pi-warmup",
`完成:session=${result.sessionId} send=${result.sendEnabled ? "1" : "0"} elapsedMs=${result.elapsedMs}`,
);
}).catch((error) => {
logPrefix("pi-warmup", `失败但不阻塞 dev-hot${error.message}`);
});
}
function shutdown(code) {
if (shuttingDown) {
return;
@@ -857,6 +883,7 @@ async function main() {
for (const task of tasks) {
startTask(task);
}
schedulePiLabWarmup(frontendUrl);
}
if (require.main === module) {
@@ -881,6 +908,7 @@ module.exports = {
resolveOpenHubHealthPlan,
resolveRuntimePlan,
resolveBackendExecutable,
schedulePiLabWarmup,
shouldStartBackend,
shouldStartOpenHub,
stopStaleMnoteWebCargoProcesses,
+2
View File
@@ -94,6 +94,8 @@ function buildDevHotEnv(baseEnv = process.env) {
OPENHUB_HEALTH_URL: String(baseEnv.OPENHUB_HEALTH_URL || `${openhubBaseUrl.replace(/\/+$/, "")}/api/health`).trim(),
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: String(baseEnv.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
MNOTE_PAGE_AI_PI_WARMUP: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP ?? "1").trim() || "1",
MNOTE_PAGE_AI_PI_WARMUP_SEND: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP_SEND ?? "1").trim() || "1",
};
if (controlPlaneBackend === "libsql-local" || controlPlaneBackend === "turso-local" || controlPlaneBackend === "turso") {
env.MNOTE_TURSO_LOCAL_PATH =
+63 -39
View File
@@ -33,20 +33,20 @@ async function setupWorkspaceAccess(requestContext, baseUrl, options) {
[
{
kind: "setupWorkspace",
userId: actorId,
user_id: actorId,
email: options.email || `${actorId}@example.com`,
username: options.username || actorId,
displayName: options.displayName || actorId,
display_name: options.displayName || actorId,
role: options.role || "user",
workspaceId: options.workspaceId,
workspaceName: options.workspaceName || "MNote Smoke Workspace",
rootUri,
rootPath,
sourceKind: options.sourceKind || "local_folder",
workspace_id: options.workspaceId,
workspace_name: options.workspaceName || "MNote Smoke Workspace",
root_uri: rootUri,
root_path: rootPath,
source_kind: options.sourceKind || "local_folder",
permission: options.permission || "write",
capabilities: options.capabilities || ["ai"],
grantSource: options.grantSource || "smoke",
grantCreatedBy: options.grantCreatedBy || actorId,
grant_source: options.grantSource || "smoke",
grant_created_by: options.grantCreatedBy || actorId,
},
],
options.timeoutMs,
@@ -61,19 +61,42 @@ async function seedAiRuntime(requestContext, baseUrl, options) {
{
kind: "seedAiRuntime",
id: options.id,
userId: options.userId,
workspaceId: options.workspaceId,
documentId: options.documentId,
sessionId: options.sessionId,
runId: options.runId,
user_id: options.userId,
workspace_id: options.workspaceId,
document_id: options.documentId,
session_id: options.sessionId,
run_id: options.runId,
title: options.title,
profile: options.profile || "reasonix",
acpRuntime: options.acpRuntime || options.profile || "reasonix",
traceId: options.traceId,
acp_runtime: options.acpRuntime || options.profile || "reasonix",
trace_id: options.traceId,
status: options.status || "running",
runtimeJson: options.runtimeJson || {},
payloadJson: options.payloadJson || {},
events: options.events || [],
runtime_json: options.runtimeJson || {},
payload_json: options.payloadJson || {},
events: (options.events || []).map((event) => ({
id: event.id,
event_type: event.event_type || event.eventType,
payload_json: event.payload_json || event.payloadJson || {},
})),
},
],
options.timeoutMs,
);
}
async function seedAiPolicy(requestContext, baseUrl, options) {
return postDevSeed(
requestContext,
baseUrl,
[
{
kind: "seedAiPolicy",
id: options.id,
user_id: options.userId,
workspace_id: options.workspaceId,
allowed_roots_json: options.allowedRootsJson || [],
model_policy_json: options.modelPolicyJson || {},
quota_json: options.quotaJson || {},
},
],
options.timeoutMs,
@@ -87,8 +110,8 @@ async function clearRuntimeEvents(requestContext, baseUrl, options) {
[
{
kind: "clearRuntimeEvents",
userId: options.userId,
runId: options.runId,
user_id: options.userId,
run_id: options.runId,
},
],
options.timeoutMs,
@@ -102,8 +125,8 @@ async function getAiRuntimeRun(requestContext, baseUrl, options) {
[
{
kind: "getAiRuntimeRun",
userId: options.userId,
runId: options.runId,
user_id: options.userId,
run_id: options.runId,
},
],
options.timeoutMs,
@@ -118,10 +141,10 @@ async function listAiRuntimeRuns(requestContext, baseUrl, options) {
[
{
kind: "listAiRuntimeRuns",
userId: options.userId,
workspaceId: options.workspaceId,
documentId: options.documentId,
sessionId: options.sessionId,
user_id: options.userId,
workspace_id: options.workspaceId,
document_id: options.documentId,
session_id: options.sessionId,
limit: options.limit,
},
],
@@ -137,10 +160,10 @@ async function listAiRuntimeEvents(requestContext, baseUrl, options) {
[
{
kind: "listAiRuntimeEvents",
userId: options.userId,
runId: options.runId,
user_id: options.userId,
run_id: options.runId,
limit: options.limit,
eventType: options.eventType,
event_type: options.eventType,
},
],
options.timeoutMs,
@@ -155,9 +178,9 @@ async function countAiRuntimeEvents(requestContext, baseUrl, options) {
[
{
kind: "countAiRuntimeEvents",
userId: options.userId,
runId: options.runId,
eventType: options.eventType,
user_id: options.userId,
run_id: options.runId,
event_type: options.eventType,
},
],
options.timeoutMs,
@@ -172,9 +195,9 @@ async function findExternalConversationBinding(requestContext, baseUrl, options)
[
{
kind: "findExternalConversationBinding",
userId: options.userId,
workspaceId: options.workspaceId,
mnoteSessionId: options.mnoteSessionId,
user_id: options.userId,
workspace_id: options.workspaceId,
mnote_session_id: options.mnoteSessionId,
provider: options.provider,
},
],
@@ -190,9 +213,9 @@ async function listExternalConversationBindings(requestContext, baseUrl, options
[
{
kind: "listExternalConversationBindings",
userId: options.userId,
workspaceId: options.workspaceId,
mnoteSessionId: options.mnoteSessionId,
user_id: options.userId,
workspace_id: options.workspaceId,
mnote_session_id: options.mnoteSessionId,
limit: options.limit,
},
],
@@ -205,6 +228,7 @@ module.exports = {
postDevSeed,
setupWorkspaceAccess,
seedAiRuntime,
seedAiPolicy,
clearRuntimeEvents,
getAiRuntimeRun,
listAiRuntimeRuns,
+241
View File
@@ -0,0 +1,241 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
function isEnabledEnv(value) {
const normalized = String(value || "").trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function actorSegment(actorId) {
const value = String(actorId || "").trim().replace(/[/:\\]/g, "_");
return value || "mnote-e2e";
}
function rootUriFromPath(rootPath) {
return `file://${path.resolve(rootPath)}`;
}
function resolvePiLabWarmupPlan(env = process.env, rootDir = path.resolve(__dirname, "../..")) {
const actorId = String(env.MNOTE_PAGE_AI_PI_WARMUP_ACTOR_ID || env.MNOTE_E2E_ACTOR_ID || "mnote-e2e").trim();
const defaultRootPath = String(
env.MNOTE_PAGE_AI_PI_WARMUP_ROOT_PATH ||
env.MNOTE_E2E_ROOT_PATH ||
"/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space",
).trim();
const rootUri = String(
env.MNOTE_PAGE_AI_PI_WARMUP_ROOT_URI ||
env.MNOTE_E2E_ROOT_URI ||
rootUriFromPath(fs.existsSync(defaultRootPath) ? defaultRootPath : rootDir),
).trim();
const baseUrl = String(
env.MNOTE_PAGE_AI_PI_WARMUP_BASE_URL ||
env.MNOTE_UI_BASE_URL ||
env.MNOTE_WEB_PUBLIC_URL ||
"http://127.0.0.1:3000",
).replace(/\/+$/, "");
return {
enabled: isEnabledEnv(env.MNOTE_PAGE_AI_PI_WARMUP),
sendEnabled: isEnabledEnv(env.MNOTE_PAGE_AI_PI_WARMUP_SEND),
baseUrl,
actorId,
auth: String(env.MNOTE_PAGE_AI_PI_WARMUP_AUTH || "Bearer pi-lab-smoke").trim(),
sessionId: String(
env.MNOTE_PAGE_AI_PI_WARMUP_SESSION_ID || `pi_lab_dev_warm_${actorSegment(actorId)}`,
).trim(),
rootUri,
workspaceId: String(
env.MNOTE_PAGE_AI_PI_WARMUP_WORKSPACE_ID ||
env.MNOTE_E2E_WORKSPACE_ID ||
`local-ws:${actorId}:my-space`,
).trim(),
pagePath: String(env.MNOTE_PAGE_AI_PI_WARMUP_PAGE_PATH || "pi-lab-warmup.md").trim(),
pageTitle: String(env.MNOTE_PAGE_AI_PI_WARMUP_PAGE_TITLE || "Pi Lab dev warmup").trim(),
modelProvider: String(
env.MNOTE_PAGE_AI_PI_WARMUP_MODEL_PROVIDER || env.MNOTE_PAGE_AI_PI_DEFAULT_MODEL_PROVIDER || "",
).trim(),
modelId: String(
env.MNOTE_PAGE_AI_PI_WARMUP_MODEL_ID ||
env.MNOTE_PAGE_AI_PI_DEFAULT_MODEL ||
env.MNOTE_PAGE_AI_PI_DEFAULT_MODEL_ID ||
"",
).trim(),
thinkingLevel: String(
env.MNOTE_PAGE_AI_PI_WARMUP_THINKING ||
env.MNOTE_PAGE_AI_PI_THINKING ||
"medium",
).trim(),
permissionMode: String(env.MNOTE_PAGE_AI_PI_WARMUP_PERMISSION_MODE || "plan").trim(),
prompt: String(env.MNOTE_PAGE_AI_PI_WARMUP_PROMPT || "只回复 OK,不要解释。").trim(),
readyTimeoutMs: Number.parseInt(env.MNOTE_PAGE_AI_PI_WARMUP_READY_TIMEOUT_MS || "180000", 10),
requestTimeoutMs: Number.parseInt(env.MNOTE_PAGE_AI_PI_WARMUP_REQUEST_TIMEOUT_MS || "30000", 10),
sendTimeoutMs: Number.parseInt(env.MNOTE_PAGE_AI_PI_WARMUP_SEND_TIMEOUT_MS || "120000", 10),
};
}
function warmupHeaders(plan) {
return {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: plan.auth,
"x-mnote-actor-id": plan.actorId,
};
}
async function sleep(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchJson(url, options, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
return { response, body };
} finally {
clearTimeout(timer);
}
}
async function waitForStatus(plan, log) {
const startedAt = Date.now();
const statusUrl = `${plan.baseUrl}/api/page-ai/pi/status`;
let lastError = "";
while (Date.now() - startedAt <= plan.readyTimeoutMs) {
try {
const result = await fetchJson(statusUrl, {
method: "GET",
headers: warmupHeaders(plan),
}, Math.min(plan.requestTimeoutMs, 5000));
if (result.response.ok) {
return result.body;
}
lastError = `HTTP ${result.response.status}`;
} catch (error) {
lastError = error && error.message ? error.message : String(error);
}
await sleep(1000);
}
throw new Error(`等待 Pi Lab status 超时:${lastError || statusUrl}`);
}
async function waitForWarmupReply(plan, log) {
const startedAt = Date.now();
const eventsUrl = `${plan.baseUrl}/api/page-ai/pi/sessions/${encodeURIComponent(plan.sessionId)}/events?limit=200`;
while (Date.now() - startedAt <= plan.sendTimeoutMs) {
const result = await fetchJson(eventsUrl, {
method: "GET",
headers: warmupHeaders(plan),
}, Math.min(plan.requestTimeoutMs, 5000));
if (result.response.ok) {
const events = Array.isArray(result.body.events) ? result.body.events : [];
const terminal = events.some((event) => {
if (!event || event.eventType !== "pi_rpc_event") return false;
const payload = event.payload || {};
const type = payload.type || (payload.assistantMessageEvent && payload.assistantMessageEvent.type);
return type === "agent_end" || type === "message_end" || type === "done" || type === "error";
});
const firstText = events.some((event) => {
const payload = event && event.payload ? event.payload : {};
const msg = payload.assistantMessageEvent || payload;
return msg.type === "text_delta" || msg.type === "text_start" || payload.type === "message";
});
if (terminal || firstText) return { terminal, firstText };
}
await sleep(1000);
}
log("warmup prompt 已发送,但等待首个模型事件超时;运行时仍保持可复用。");
return { terminal: false, firstText: false, timeout: true };
}
async function runPiLabWarmup(env = process.env, options = {}) {
const rootDir = options.rootDir || path.resolve(__dirname, "../..");
const log = options.log || (() => undefined);
const plan = resolvePiLabWarmupPlan(env, rootDir);
if (!plan.enabled) return { ok: true, skipped: true, reason: "disabled" };
const startedAt = Date.now();
log(`等待 Pi Lab status${plan.baseUrl}`);
const status = await waitForStatus(plan, log);
if (status.enabled === false) {
log("Pi Lab 未启用,跳过 warmup。");
return { ok: true, skipped: true, reason: "disabled_by_backend" };
}
const startBody = {
sessionId: plan.sessionId,
rootUri: plan.rootUri,
workspaceId: plan.workspaceId,
pagePath: plan.pagePath,
pageTitle: plan.pageTitle,
thinkingLevel: plan.thinkingLevel,
permissionMode: plan.permissionMode,
};
if (plan.modelProvider) startBody.modelProvider = plan.modelProvider;
if (plan.modelId) startBody.modelId = plan.modelId;
log(`启动 Pi Lab warm session${plan.sessionId}`);
const start = await fetchJson(`${plan.baseUrl}/api/page-ai/pi/start`, {
method: "POST",
headers: warmupHeaders(plan),
body: JSON.stringify(startBody),
}, plan.requestTimeoutMs);
if (!start.response.ok || start.body.ok !== true) {
throw new Error(`Pi Lab warmup start 失败:HTTP ${start.response.status} ${JSON.stringify(start.body).slice(0, 500)}`);
}
if (plan.sendEnabled) {
log("发送 Pi Lab warmup prompt;这会产生一次真实模型请求。");
const send = await fetchJson(`${plan.baseUrl}/api/page-ai/pi/send`, {
method: "POST",
headers: warmupHeaders(plan),
body: JSON.stringify({
sessionId: plan.sessionId,
message: plan.prompt,
}),
}, plan.requestTimeoutMs);
if (!send.response.ok || send.body.accepted !== true) {
throw new Error(`Pi Lab warmup send 失败:HTTP ${send.response.status} ${JSON.stringify(send.body).slice(0, 500)}`);
}
await waitForWarmupReply(plan, log);
} else {
log("已预启动 Pi runtime / MCP cache;未发送模型请求。");
}
return {
ok: true,
skipped: false,
sessionId: plan.sessionId,
sendEnabled: plan.sendEnabled,
elapsedMs: Date.now() - startedAt,
};
}
if (require.main === module) {
runPiLabWarmup(process.env, {
rootDir: path.resolve(__dirname, "../.."),
log: (message) => console.log(`[pi-warmup] ${message}`),
}).then((result) => {
if (!result.skipped) {
console.log(`[pi-warmup] 完成:session=${result.sessionId} elapsedMs=${result.elapsedMs}`);
}
}).catch((error) => {
console.error(`[pi-warmup] 失败:${error.message}`);
process.exit(1);
});
}
module.exports = {
isEnabledEnv,
resolvePiLabWarmupPlan,
runPiLabWarmup,
};
+439
View File
@@ -0,0 +1,439 @@
#!/usr/bin/env node
"use strict";
/**
* Minimal Browser QA: Block handle menu icon/layout check
* Scope: open editor hover paragraph handle click handle check menu
* NO expansion to multiple block types, pages, or features.
*/
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_BASE || "http://localhost:3000";
const EVIDENCE_DIR = path.join(__dirname, "..", "tmp", "browser-qa-evidence");
const STEPS = [];
let status = "PASS";
fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
async function screenshot(page, name) {
const fp = path.join(EVIDENCE_DIR, `${name}.png`);
await page.screenshot({ path: fp, fullPage: false });
const size = fs.statSync(fp).size;
console.log(` 📸 ${name}.png (${(size / 1024).toFixed(1)} KB)`);
return fp;
}
function step(id, desc) {
return { id, desc, status: "pending", evidence: [] };
}
async function run() {
const browser = await chromium.launch({
headless: true,
executablePath:
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ||
(fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "") ||
(fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "") ||
(fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : ""),
args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({
viewport: { width: 1280, height: 800 },
locale: "zh-CN",
});
const page = await context.newPage();
// Collect console errors
const consoleErrors = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text().slice(0, 200));
}
});
const networkErrors = [];
page.on("requestfailed", (req) => {
networkErrors.push(`${req.failure().errorText} ${req.url().slice(0, 120)}`);
});
try {
// ── Step 1: Login ──
let s1 = step("login", "Login via quick-login button");
console.log(`\n🔍 Step 1: ${s1.desc}`);
await page.goto(`${BASE}/auth`, { waitUntil: "networkidle", timeout: 15000 });
const loginBtn = page.getByRole("button", { name: "测试账号快速登录" });
await loginBtn.waitFor({ state: "visible", timeout: 8000 });
await Promise.all([
page.waitForURL((url) => !url.pathname.includes("/auth"), { timeout: 15000 }),
loginBtn.click(),
]);
await page.waitForTimeout(2000);
const afterLogin = page.url();
s1.status = afterLogin.includes("/auth") ? "FAIL" : "PASS";
s1.evidence.push(await screenshot(page, "01-after-login"));
console.log(` URL: ${afterLogin}${s1.status}`);
STEPS.push(s1);
if (s1.status === "FAIL") {
console.log("\n❌ Login failed, aborting.");
status = "FAIL";
printResult();
return;
}
// ── Step 2: Navigate to a page with content ──
let s2 = step("navigate", "Navigate to an existing page with paragraph content");
console.log(`\n🔍 Step 2: ${s2.desc}`);
await page.waitForTimeout(1500);
s2.evidence.push(await screenshot(page, "02-main-screen"));
// The sidebar lists page items under "我的页面". Click the first one.
// Sidebar items are typically list items or links inside the sidebar.
const sidebarPageItem = page.locator('[data-testid*="sidebar"] li, [data-testid*="sidebar"] a, [class*="sidebar"] li, [class*="sidebar"] a, [class*="page-tree"] li, [class*="page-tree"] a, nav li a').first();
const sidebarVisible = await sidebarPageItem.isVisible({ timeout: 5000 }).catch(() => false);
console.log(` Sidebar item visible: ${sidebarVisible}`);
let foundPage = false;
if (sidebarVisible) {
const itemText = await sidebarPageItem.textContent();
console.log(` Clicking sidebar item: "${itemText}"`);
await sidebarPageItem.click();
await page.waitForTimeout(2500);
foundPage = true;
} else {
// Fallback: try to click on any visible text that looks like a page name in the main area
const pageLink = page.locator('text=有机合成中的保护基').first();
if (await pageLink.isVisible({ timeout: 3000 }).catch(() => false)) {
console.log(` Clicking page link in main area`);
await pageLink.click();
await page.waitForTimeout(2500);
foundPage = true;
}
}
// Check if we're now in an editor (ProseMirror / contenteditable visible)
const editorCheck = await page.locator('.ProseMirror, [contenteditable="true"], .tiptap').first()
.isVisible({ timeout: 3000 }).catch(() => false);
console.log(` Editor visible after nav: ${editorCheck}`);
if (!editorCheck && foundPage) {
// Maybe we landed on a file-explorer view, try clicking the first .md page in the list
const mdLink = page.locator('text=有机合成中的保护基').first();
if (await mdLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await mdLink.click();
await page.waitForTimeout(2500);
}
}
s2.status = foundPage ? "PASS" : "BLOCKED";
s2.evidence.push(await screenshot(page, "03-page-loaded"));
console.log(` Found page: ${foundPage}${s2.status}`);
STEPS.push(s2);
if (s2.status !== "PASS") {
status = s2.status;
printResult();
return;
}
// ── Step 3: Locate paragraph block and hover handle ──
let s3 = step("hover-handle", "Hover paragraph block left handle to reveal drag handle icon");
console.log(`\n🔍 Step 3: ${s3.desc}`);
// Wait for editor content to be ready
await page.waitForTimeout(1500);
// tiptap editor renders paragraphs inside .ProseMirror or similar
// The block handle (drag handle / menu trigger) typically appears on hover
// Common selectors for tiptap paragraph blocks:
const editorArea = page.locator('.ProseMirror, [contenteditable="true"], .tiptap, [data-testid*="editor"]').first();
const editorVisible = await editorArea.isVisible({ timeout: 5000 }).catch(() => false);
console.log(` Editor area visible: ${editorVisible}`);
if (!editorVisible) {
s3.status = "BLOCKED";
s3.evidence.push(await screenshot(page, "04-no-editor"));
console.log(" ❌ No editor area found");
STEPS.push(s3);
status = "BLOCKED";
printResult();
return;
}
// Find a paragraph element inside the editor
const paragraph = page.locator('.ProseMirror p, .tiptap p, [contenteditable="true"] p').first();
const paraVisible = await paragraph.isVisible({ timeout: 5000 }).catch(() => false);
console.log(` Paragraph visible: ${paraVisible}`);
if (!paraVisible) {
s3.status = "BLOCKED";
s3.evidence.push(await screenshot(page, "04b-no-paragraph"));
console.log(" ❌ No paragraph found in editor");
STEPS.push(s3);
status = "BLOCKED";
printResult();
return;
}
// Hover near the left edge of the paragraph to trigger handle appearance
const paraBox = await paragraph.boundingBox();
console.log(` Paragraph box: ${JSON.stringify(paraBox)}`);
// Hover at the left edge of the paragraph (where the handle appears)
await page.mouse.move(paraBox.x - 5, paraBox.y + paraBox.height / 2, { steps: 10 });
await page.waitForTimeout(800);
// Take screenshot to see if handle appeared
s3.evidence.push(await screenshot(page, "05-after-hover-handle"));
// Look for handle / drag handle element
// Common handle selectors in tiptap/leptos-tiptap
const handleSelectors = [
'[data-testid*="handle"]',
'[data-testid*="drag"]',
'.block-handle',
'.drag-handle',
'.ProseMirror .handle',
'.ProseMirror [contenteditable="false"]',
'.tiptap .handle',
'.tiptap .drag-handle',
'button[aria-label*="handle" i]',
'button[aria-label*="drag" i]',
'button[aria-label*="menu" i]',
'[class*="handle"]',
'[class*="Handle"]',
'[class*="drag-handle"]',
'[class*="block-handle"]',
'.leptos-tiptap-handle',
'[data-drag-handle]',
'.ProseMirror > div:first-child .handle',
];
let handleEl = null;
let matchedSelector = null;
for (const sel of handleSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 500 }).catch(() => false)) {
handleEl = el;
matchedSelector = sel;
break;
}
}
if (handleEl) {
console.log(` ✅ Handle found with selector: ${matchedSelector}`);
s3.status = "PASS";
} else {
console.log(" ⚠️ No handle element found via standard selectors, checking DOM...");
// Dump DOM near paragraph for debugging
const parentHTML = await paragraph.evaluate((el) => {
return el.parentElement?.outerHTML?.slice(0, 2000) || "N/A";
});
console.log(` Parent HTML: ${parentHTML.slice(0, 500)}`);
// Also check if handle is inside a wrapper
const wrapperHandle = await paragraph.evaluate((el) => {
const wrapper = el.closest('[class*="block"], [class*="node"], [data-node-type], [data-type]');
if (wrapper) {
return wrapper.outerHTML.slice(0, 1500);
}
return null;
});
if (wrapperHandle) {
console.log(` Wrapper HTML: ${wrapperHandle.slice(0, 500)}`);
}
s3.status = "FAIL";
s3.evidence.push(await screenshot(page, "05b-no-handle-visible"));
}
STEPS.push(s3);
// ── Step 4: Click handle to open block menu ──
let s4 = step("click-handle", "Click handle to open block context menu");
console.log(`\n🔍 Step 4: ${s4.desc}`);
if (!handleEl) {
s4.status = "BLOCKED";
s4.evidence.push(await screenshot(page, "06-no-handle-to-click"));
console.log(" ⏭️ Skipped: no handle found");
STEPS.push(s4);
} else {
await handleEl.click();
await page.waitForTimeout(800);
s4.evidence.push(await screenshot(page, "06-after-click-handle"));
// Look for menu that appeared
const menuSelectors = [
'[data-testid*="menu"]',
'[data-testid*="slash"]',
'[role="menu"]',
'[role="listbox"]',
'.block-menu',
'.slash-menu',
'[class*="menu"]',
'[class*="Menu"]',
'[class*="popup"]',
'[class*="dropdown"]',
'.ProseMirror [contenteditable="false"] [role]',
];
let menuEl = null;
let menuSelector = null;
for (const sel of menuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 500 }).catch(() => false)) {
menuEl = el;
menuSelector = sel;
break;
}
}
if (menuEl) {
console.log(` ✅ Menu appeared with selector: ${menuSelector}`);
s4.status = "PASS";
// Check menu contents - icons and layout
const menuItems = await menuEl.locator('[role="menuitem"], [role="option"], li, button').all();
console.log(` Menu items count: ${menuItems.length}`);
// Check for icons within menu items
let iconsFound = 0;
for (const item of menuItems.slice(0, 10)) {
const hasIcon = await item.locator('svg, img, [class*="icon"], [class*="Icon"]').count();
if (hasIcon > 0) iconsFound++;
}
console.log(` Items with icons: ${iconsFound}/${Math.min(menuItems.length, 10)}`);
s4.evidence.push(await screenshot(page, "07-menu-open-detail"));
// Dump menu DOM for evidence
const menuHTML = await menuEl.evaluate((el) => el.outerHTML.slice(0, 3000));
const menuTexts = await menuEl.evaluate((el) => {
return Array.from(el.querySelectorAll('*')).map(e => e.textContent?.trim()).filter(Boolean).slice(0, 30);
});
console.log(` Menu texts: ${JSON.stringify(menuTexts.slice(0, 15))}`);
} else {
console.log(" ⚠️ No menu appeared after clicking handle");
s4.status = "FAIL";
// Check if maybe a different element appeared
const anyPopup = await page.locator('[class*="popup"], [class*="overlay"], [class*="modal"], [class*="dropdown"], [class*="tooltip"]').first();
const popupVisible = await anyPopup.isVisible({ timeout: 500 }).catch(() => false);
console.log(` Any popup visible: ${popupVisible}`);
s4.evidence.push(await screenshot(page, "07b-no-menu"));
}
STEPS.push(s4);
}
// ── Step 5: Layout / visual check ──
let s5 = step("layout-check", "Check handle icon, menu icons and menu layout");
console.log(`\n🔍 Step 5: ${s5.desc}`);
if (s4.status !== "PASS") {
s5.status = "BLOCKED";
s5.evidence.push(await screenshot(page, "08-layout-check-skipped"));
console.log(" ⏭️ Skipped: menu not confirmed open");
} else {
// Check menu layout: bounding box, items alignment
const menuBox = await menuEl.boundingBox();
console.log(` Menu bounding box: ${JSON.stringify(menuBox)}`);
// Verify menu is not overlapping editor content badly
const editorBox = await editorArea.boundingBox();
if (menuBox && editorBox) {
const isOnScreen = menuBox.x >= 0 && menuBox.y >= 0 &&
menuBox.x + menuBox.width <= 1280 && menuBox.y + menuBox.height <= 800;
const hasReasonableSize = menuBox.width > 50 && menuBox.height > 30;
console.log(` On screen: ${isOnScreen}, Reasonable size: ${hasReasonableSize}`);
s5.status = (isOnScreen && hasReasonableSize) ? "PASS" : "FAIL";
} else {
s5.status = "FAIL";
}
s5.evidence.push(await screenshot(page, "08-layout-final"));
}
STEPS.push(s5);
// Console/network summary
printResult(consoleErrors, networkErrors);
} catch (err) {
console.error(`\n💥 Fatal error: ${err.message}`);
status = "FAIL";
try { await screenshot(page, "99-fatal-error"); } catch (_) {}
printResult(consoleErrors, networkErrors, err);
} finally {
await browser.close();
}
}
function printResult(consoleErrors = [], networkErrors = [], fatalErr = null) {
const allPass = STEPS.every((s) => s.status === "PASS" || s.status === "no-op");
const anyBlocked = STEPS.some((s) => s.status === "BLOCKED");
if (!fatalErr && allPass) status = "PASS";
else if (anyBlocked) status = "BLOCKED";
else if (fatalErr) status = "FAIL";
console.log("\n" + "═".repeat(70));
console.log("BROWSER_QA_RESULT");
console.log("═".repeat(70));
console.log(`STATUS: ${status}`);
console.log(`steps_run: ${STEPS.length}`);
console.log(`steps_detail:`);
for (const s of STEPS) {
console.log(` ${s.id}: ${s.status}${s.desc}`);
}
console.log(`actual_vs_expected:`);
for (const s of STEPS) {
const expected = s.id === "login" ? "Redirected to workspace"
: s.id === "navigate" ? "Editor page loaded with paragraph content"
: s.id === "hover-handle" ? "Drag handle icon visible on left of paragraph"
: s.id === "click-handle" ? "Block context menu opens with icons and proper layout"
: s.id === "layout-check" ? "Menu on screen, reasonable size, icons present"
: "N/A";
console.log(` ${s.id}: expected="${expected}" actual="${s.status}"`);
}
console.log(`evidence_paths:`);
for (const s of STEPS) {
for (const e of s.evidence) {
console.log(` ${s.id}: ${e}`);
}
}
console.log(`console_network_summary:`);
console.log(` console_errors: ${consoleErrors.length}`);
for (const e of consoleErrors.slice(0, 5)) console.log(` - ${e}`);
console.log(` network_errors: ${networkErrors.length}`);
for (const e of networkErrors.slice(0, 5)) console.log(` - ${e}`);
const hasHandleBug = STEPS.some(s =>
(s.id === "hover-handle" && s.status === "FAIL") ||
(s.id === "click-handle" && s.status === "FAIL")
);
console.log(`candidate_bug:`);
if (hasHandleBug) {
console.log(` title: "Block handle icon not visible or block menu does not open on handle click"`);
console.log(` scope: "leptos-tiptap editor paragraph block handle interaction"`);
console.log(` symptoms: "Handle icon does not appear on hover, or clicking handle does not open context menu"`);
} else if (status === "BLOCKED") {
console.log(` title: "BLOCKED - Could not reach editor or find paragraph block"`);
console.log(` scope: "test infrastructure / page navigation"`);
} else {
console.log(` title: "none detected in this run"`);
}
console.log(`needs_triage: ${hasHandleBug || status === "BLOCKED" ? "YES" : "NO"}`);
console.log("═".repeat(70));
}
run().catch((err) => {
console.error("Unhandled:", err);
process.exit(1);
});
+699
View File
@@ -0,0 +1,699 @@
#!/usr/bin/env node
"use strict";
const path = require("path");
const { chromium } = require("playwright");
const TIMEOUT_MS = 30_000;
const SCREENSHOT_DIR = path.join(
process.env.MNOTE_QA_SCREENSHOT_DIR || path.resolve(__dirname, "../tmp/qa-block-handle"),
);
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
const WORKSPACE_DIR = path.join(
process.env.MNOTE_QA_WORKSPACE_DIR || path.resolve(__dirname, "../tmp/mnote-qa-handle"),
);
const TEST_MARKDOWN = `\
# 块手柄测试页
第一段正文用于测试段落块的手柄和菜单
第二段正文用于测试第二个段落块
## 二级标题块
标题下方正文段落
- 列表项 A
- 列表项 B
\`\`\`
代码块示例
\`\`\`
`;
function uniqueSuffix() {
return Math.random().toString(36).slice(2, 8);
}
function ensureDirSync(dir) {
const fs = require("fs");
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function writeWsManifest() {
const fs = require("fs");
ensureDirSync(path.join(WORKSPACE_DIR, ".mnote"));
fs.writeFileSync(
path.join(WORKSPACE_DIR, ".mnote/workspace.json"),
JSON.stringify({
name: "QA Block Handle Test",
version: 1,
createdAt: new Date().toISOString(),
}),
);
fs.writeFileSync(path.join(WORKSPACE_DIR, "BlockHandleTest.md"), TEST_MARKDOWN);
}
async function ensureAuthenticated(page, requestContext) {
const response = await requestContext.fetch(`${BASE_URL}/api/auth/whoami`, {
method: "GET",
timeout: 10_000,
});
const body = await response.json().catch(() => null);
if (body?.user) return body.user;
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
await page.waitForTimeout(1000);
const btn = page.getByRole("button", { name: "测试账号快速登录" });
if (await btn.isVisible().catch(() => false)) {
await btn.click();
await page.waitForURL("**/documents/**", { timeout: TIMEOUT_MS }).catch(() => {});
await page.waitForTimeout(2000);
} else {
await page.getByLabel("邮箱").fill(TEST_EMAIL);
await page.getByLabel("密码").fill(TEST_PASSWORD);
await page.getByRole("button", { name: /登录|Login|Sign in/i }).click();
await page.waitForURL("**/documents/**", { timeout: TIMEOUT_MS }).catch(() => {});
await page.waitForTimeout(2000);
}
return null;
}
async function main() {
const fs = require("fs");
ensureDirSync(SCREENSHOT_DIR);
writeWsManifest();
const suffix = uniqueSuffix();
const rootUri = `file://${WORKSPACE_DIR}`;
const docUrl =
`${BASE_URL}/documents/local-md%3ABlockHandleTest.md` +
`?sourceKind=local_folder&rootUri=${encodeURIComponent(rootUri)}&treeView=filetree`;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
page.setDefaultTimeout(TIMEOUT_MS);
const consoleErrors = [];
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
const result = {
pass: false,
steps: [],
screenshots: [],
consoleErrors,
failures: [],
};
const step = (name, extra = {}) => {
const s = { step: name, status: "ok", ...extra };
result.steps.push(s);
return s;
};
const fail = (name, message, extra = {}) => {
const s = { step: name, status: "fail", message, ...extra };
result.steps.push(s);
result.failures.push({ step: name, status: "fail", ...extra });
return s;
};
try {
await ensureAuthenticated(page, page.request);
step("auth");
await page.goto(docUrl, { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
await page
.locator('.tiptap.ProseMirror, [contenteditable="true"]')
.first()
.waitFor({ state: "visible", timeout: TIMEOUT_MS })
.catch(() => {});
await page.waitForTimeout(2000);
step("navigate", { url: docUrl });
// ─────────────── Helper functions ───────────────
async function screenshot(name) {
const filePath = path.join(SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: filePath, fullPage: false });
result.screenshots.push(filePath);
return filePath;
}
async function getEditor() {
const editor = page.locator('.tiptap.ProseMirror, [contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: 5000 });
return editor;
}
async function getAllBlocks() {
const editor = await getEditor();
const children = editor.locator("> *").filter({
has: page.locator(
".ProseMirror-gapcursor, .ProseMirror-selection, .ProseMirror-cursor",
),
});
// Fallback: just get top-level children
const blocks = editor.locator("> *");
const count = await blocks.count();
return { editor, blocks, count };
}
async function closeAnyOpenMenu() {
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
}
async function getBlockHandle() {
// The block handle (grip icon) appears on hover near the left edge of a block
// It's typically a div with a drag handle icon
const handle = page.locator('[class*="block-handle"], [class*="drag-handle"], [data-block-handle]').first();
if (await handle.isVisible().catch(() => false)) return handle;
// Try finding by the grip icon pattern
const grip = page.locator('.editor-block-handle, .ProseMirror .block-handle, .ProseMirror [contenteditable] > div > div:first-child').first();
return grip;
}
async function findHandleNearBlock(blockEl, blockIndex) {
// Hover over the block to trigger handle visibility
const box = await blockEl.boundingBox();
if (!box) return null;
// Hover at the left edge of the block to trigger the handle
await page.mouse.move(box.x - 10, box.y + box.height / 2);
await page.waitForTimeout(500);
// Look for visible handles
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, .block-handle-wrapper, [data-testid*="handle"]');
const count = await handles.count();
for (let i = 0; i < count; i++) {
const h = handles.nth(i);
if (await h.isVisible().catch(() => false)) {
return h;
}
}
return null;
}
async function openMenuViaHover(blockEl) {
const box = await blockEl.boundingBox();
if (!box) throw new Error("Block has no bounding box");
// Move to left edge to trigger handle
await page.mouse.move(box.x - 5, box.y + box.height / 2);
await page.waitForTimeout(500);
// Find the visible handle
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, .block-handle-wrapper, [data-testid*="handle"]');
const count = await handles.count();
let handle = null;
for (let i = 0; i < count; i++) {
const h = handles.nth(i);
if (await h.isVisible().catch(() => false)) {
handle = h;
break;
}
}
if (!handle) {
// Try clicking at the left edge area
await page.mouse.click(box.x - 2, box.y + box.height / 2);
await waitForMenuVisible();
return;
}
// Click the handle to open the menu
const handleBox = await handle.boundingBox();
if (handleBox) {
await page.mouse.click(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
} else {
await handle.click();
}
await waitForMenuVisible();
}
async function waitForMenuVisible() {
// Wait for menu container to appear
await page.waitForTimeout(500);
// Try various menu selectors
const menuSelectors = [
'[class*="block-menu"]',
'[class*="block-action"]',
'[class*="slash-menu"]',
'[class*="floating-menu"]',
'[role="menu"]',
'[data-testid*="menu"]',
'.ProseMirror [contenteditable="false"] [class*="menu"]',
];
for (const sel of menuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
return el;
}
}
return null;
}
// ─────────────── TEST 1: Block Handle Visibility ───────────────
{
const { editor, blocks, count } = await getAllBlocks();
step("editor-visible", { blockCount: count });
if (count === 0) {
throw new Error("No blocks found in editor");
}
// Test first paragraph block
const firstBlock = blocks.nth(0);
const firstBox = await firstBlock.boundingBox();
if (!firstBox) {
throw new Error("First block has no bounding box");
}
// Hover over left edge of first block
await page.mouse.move(firstBox.x - 5, firstBox.y + firstBox.height / 2);
await page.waitForTimeout(800);
await screenshot("01-handle-visible");
// Check if any handle appeared
const handleSelectors = [
'[class*="block-handle"]',
'[class*="drag-handle"]',
'.drag-handle',
'[data-testid*="handle"]',
'svg[data-block-handle]',
'[contenteditable="true"] ~ div',
];
let handleFound = false;
for (const sel of handleSelectors) {
const els = page.locator(sel);
const cnt = await els.count();
for (let i = 0; i < cnt; i++) {
if (await els.nth(i).isVisible().catch(() => false)) {
handleFound = true;
break;
}
}
if (handleFound) break;
}
if (handleFound) {
step("handle-visible", { selector: "matched" });
} else {
fail("handle-visible", "Block handle did not appear on hover");
}
}
// ─────────────── TEST 2: Open Menu ───────────────
{
const { editor, blocks, count } = await getAllBlocks();
const firstBlock = blocks.nth(0);
const firstBox = await firstBlock.boundingBox();
if (firstBox) {
// Hover to show handle
await page.mouse.move(firstBox.x - 5, firstBox.y + firstBox.height / 2);
await page.waitForTimeout(600);
// Find handle and click it
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]');
const hCount = await handles.count();
let clicked = false;
for (let i = 0; i < hCount; i++) {
const h = handles.nth(i);
if (await h.isVisible().catch(() => false)) {
const hBox = await h.boundingBox();
if (hBox) {
await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2);
clicked = true;
}
break;
}
}
if (!clicked) {
// Fallback: click near the left edge
await page.mouse.click(firstBox.x - 2, firstBox.y + firstBox.height / 2);
}
await page.waitForTimeout(800);
await screenshot("02-menu-opened");
// Find the menu
const menuSelectors = [
'[class*="block-menu"]',
'[class*="block-action"]',
'[class*="slash-menu"]',
'[class*="floating-menu"]',
'[role="menu"]',
];
let menuEl = null;
for (const sel of menuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
menuEl = el;
break;
}
}
if (menuEl) {
const menuBox = await menuEl.boundingBox();
step("menu-opened", {
width: Math.round(menuBox?.width || 0),
height: Math.round(menuBox?.height || 0),
});
// Measure menu items
const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]');
const itemCount = await menuItems.count();
step("menu-item-count", { count: itemCount });
// Check menu items text
const itemTexts = [];
for (let i = 0; i < Math.min(itemCount, 20); i++) {
const text = await menuItems.nth(i).innerText().catch(() => "");
itemTexts.push(text.trim());
}
step("menu-items-text", { items: itemTexts });
await screenshot("03-menu-detail");
} else {
fail("menu-opened", "Menu did not appear after clicking handle");
}
// Close menu
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
}
}
// ─────────────── TEST 3: Heading Block Handle ───────────────
{
const { editor, blocks, count } = await getAllBlocks();
// Find the heading block (h2)
let headingBlock = null;
for (let i = 0; i < count; i++) {
const block = blocks.nth(i);
const tagName = await block.evaluate((el) => el.tagName.toLowerCase());
if (tagName === "h2" || tagName === "h1" || tagName === "h3") {
headingBlock = block;
break;
}
}
if (!headingBlock) {
fail("heading-block", "Could not find heading block");
} else {
const hBox = await headingBlock.boundingBox();
if (hBox) {
// Hover over heading block
await page.mouse.move(hBox.x - 5, hBox.y + hBox.height / 2);
await page.waitForTimeout(600);
await screenshot("04-heading-hover");
// Find and click handle
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]');
const hCount = await handles.count();
let clicked = false;
for (let i = 0; i < hCount; i++) {
const h = handles.nth(i);
if (await h.isVisible().catch(() => false)) {
const hBox2 = await h.boundingBox();
if (hBox2) {
await page.mouse.click(hBox2.x + hBox2.width / 2, hBox2.y + hBox2.height / 2);
clicked = true;
}
break;
}
}
if (!clicked) {
await page.mouse.click(hBox.x - 2, hBox.y + hBox.height / 2);
}
await page.waitForTimeout(800);
await screenshot("05-heading-menu");
// Find the menu
const menuSelectors = [
'[class*="block-menu"]',
'[class*="block-action"]',
'[class*="slash-menu"]',
'[class*="floating-menu"]',
'[role="menu"]',
];
let menuEl = null;
for (const sel of menuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
menuEl = el;
break;
}
}
if (menuEl) {
const menuBox = await menuEl.boundingBox();
step("heading-menu-opened", {
width: Math.round(menuBox?.width || 0),
height: Math.round(menuBox?.height || 0),
});
// Check menu items for heading-specific items
const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]');
const itemCount = await menuItems.count();
step("heading-menu-item-count", { count: itemCount });
// Get menu items text
const itemTexts = [];
for (let i = 0; i < Math.min(itemCount, 20); i++) {
const text = await menuItems.nth(i).innerText().catch(() => "");
itemTexts.push(text.trim());
}
step("heading-menu-items-text", { items: itemTexts });
await screenshot("06-heading-menu-detail");
} else {
fail("heading-menu-opened", "Menu did not appear for heading block");
}
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
}
}
}
// ─────────────── TEST 4: List Block Handle ───────────────
{
const { editor, blocks, count } = await getAllBlocks();
// Find list item block
let listBlock = null;
for (let i = 0; i < count; i++) {
const block = blocks.nth(i);
const tagName = await block.evaluate((el) => el.tagName.toLowerCase());
if (tagName === "li" || tagName === "ul" || tagName === "ol") {
listBlock = block;
break;
}
}
if (!listBlock) {
fail("list-block", "Could not find list block");
} else {
const lBox = await listBlock.boundingBox();
if (lBox) {
await page.mouse.move(lBox.x - 5, lBox.y + lBox.height / 2);
await page.waitForTimeout(600);
await screenshot("06-list-hover");
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]');
const hCount = await handles.count();
let clicked = false;
for (let i = 0; i < hCount; i++) {
const h = handles.nth(i);
if (await h.isVisible().catch(() => false)) {
const hBox = await h.boundingBox();
if (hBox) {
await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2);
clicked = true;
}
break;
}
}
if (!clicked) {
await page.mouse.click(lBox.x - 2, lBox.y + lBox.height / 2);
}
await page.waitForTimeout(800);
await screenshot("07-list-menu");
const menuSelectors = [
'[class*="block-menu"]',
'[class*="block-action"]',
'[class*="slash-menu"]',
'[class*="floating-menu"]',
'[role="menu"]',
];
let menuEl = null;
for (const sel of menuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
menuEl = el;
break;
}
}
if (menuEl) {
const menuBox = await menuEl.boundingBox();
step("list-menu-opened", {
width: Math.round(menuBox?.width || 0),
height: Math.round(menuBox?.height || 0),
});
const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]');
const itemCount = await menuItems.count();
step("list-menu-item-count", { count: itemCount });
await screenshot("08-list-menu-detail");
} else {
fail("list-menu-opened", "Menu did not appear for list block");
}
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
}
}
}
// ─────────────── TEST 5: Code Block Handle ───────────────
{
const { editor, blocks, count } = await getAllBlocks();
let codeBlock = null;
for (let i = 0; i < count; i++) {
const block = blocks.nth(i);
const tagName = await block.evaluate((el) => el.tagName.toLowerCase());
if (tagName === "pre" || tagName === "code") {
codeBlock = block;
break;
}
}
if (!codeBlock) {
fail("code-block", "Could not find code block");
} else {
const cBox = await codeBlock.boundingBox();
if (cBox) {
await page.mouse.move(cBox.x - 5, cBox.y + cBox.height / 2);
await page.waitForTimeout(600);
await screenshot("09-code-hover");
const handles = page.locator('[class*="block-handle"], [class*="drag-handle"], .drag-handle, [data-testid*="handle"]');
const hCount = await handles.count();
let clicked = false;
for (let i = 0; i < hCount; i++) {
const h = handles.nth(i);
if (await h.isVisible().catch(() => false)) {
const hBox = await h.boundingBox();
if (hBox) {
await page.mouse.click(hBox.x + hBox.width / 2, hBox.y + hBox.height / 2);
clicked = true;
}
break;
}
}
if (!clicked) {
await page.mouse.click(cBox.x - 2, cBox.y + cBox.height / 2);
}
await page.waitForTimeout(800);
await screenshot("10-code-menu");
const menuSelectors = [
'[class*="block-menu"]',
'[class*="block-action"]',
'[class*="slash-menu"]',
'[class*="floating-menu"]',
'[role="menu"]',
];
let menuEl = null;
for (const sel of menuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 1000 }).catch(() => false)) {
menuEl = el;
break;
}
}
if (menuEl) {
const menuBox = await menuEl.boundingBox();
step("code-menu-opened", {
width: Math.round(menuBox?.width || 0),
height: Math.round(menuBox?.height || 0),
});
const menuItems = menuEl.locator('[class*="menu-item"], [role="menuitem"], [class*="action-item"]');
const itemCount = await menuItems.count();
step("code-menu-item-count", { count: itemCount });
await screenshot("11-code-menu-detail");
} else {
fail("code-menu-opened", "Menu did not appear for code block");
}
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
}
}
}
result.pass = result.failures.length === 0;
} catch (err) {
result.steps.push({ step: "exception", status: "fail", message: String(err) });
await screenshot("99-error").catch(() => {});
} finally {
await browser.close();
}
// Cleanup temp workspace
try {
const fs = require("fs");
fs.rmSync(WORKSPACE_DIR, { recursive: true, force: true });
} catch {}
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
process.exit(result.pass ? 0 : 1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -205,6 +205,13 @@ check(
"Found mnote-account-ai-management element with AI 管理 text"
);
check(
"Account menu no longer exposes retired 授权管理 entry",
!sidebarWorkspaceJs.includes('data-testid="mnote-account-access-policy"') &&
!sidebarWorkspaceJs.includes("openAdminAccessPolicyDialog"),
"Retired access-policy modal entry is absent from account menu runtime"
);
check(
"AI management entry navigates to /admin/ai or /user/ai",
sidebarWorkspaceJs.includes("'/admin/ai'") &&
@@ -294,9 +301,9 @@ check(
);
// --------------------------------------------------------------------------
// Section 6: OpenHub / Pi Lab 独立入口未删除
// Section 6: Pi Rust 主入口与 OpenHub 兼容边界
// --------------------------------------------------------------------------
console.log("\n-- 6. OpenHub / Pi Lab 独立入口未删除 --");
console.log("\n-- 6. Pi Rust 主入口与 OpenHub 兼容边界 --");
check(
"OpenHub agent route /page-ai/openhub/ai exists",
@@ -337,6 +344,25 @@ check(
"POST start, send, abort"
);
check(
"AI management service panel treats Pi Rust as default Page AI runtime",
aiAdminRs.includes("Pi Rust 是默认 Page AI runtime") &&
aiAdminRs.includes("Pi Rust Page AI") &&
aiAdminRs.includes("runtimeImplementation") &&
aiAdminRs.includes("runtimeBinary") &&
aiAdminRs.includes("runtimeAvailable") &&
aiAdminRs.includes("runtimeError"),
"Pi Rust service panel exposes runtime implementation, binary, availability, and error"
);
check(
"OpenHub is described as compatibility/admin boundary, not default chat",
aiAdminRs.includes("迁移期兼容 / admin 边界") &&
aiAdminRs.includes("不再作为默认 Page AI chat") &&
!aiAdminRs.includes("OpenHub 仍是默认 Page AI"),
"OpenHub remains only as migration compatibility boundary"
);
// --------------------------------------------------------------------------
// Section 7: Admin 原子策略 GET/PUT
// --------------------------------------------------------------------------
@@ -432,9 +458,8 @@ check(
pageAiPiRs.includes("PiLabToolFacade") &&
pageAiPiRs.includes("omniroute_api_key") &&
pageAiPiRs.includes("env_trimmed") &&
!pageAiPiRs.includes('"sk-') &&
!pageAiPiRs.includes('"secret') &&
!pageAiPiRs.includes('"API_KEY'),
pageAiPiRs.includes("MNOTE_PAGE_AI_PI_OMNIROUTE_API_KEY") &&
!pageAiPiRs.includes('"sk-'),
"Pi Lab uses env-based key via omniroute_api_key() and env_trimmed"
);
+17 -7
View File
@@ -2,10 +2,11 @@
// Pi Lab API endpoint smoke
// 验证 Pi Lab API 路由在 mnote-web 开发环境下的响应
// 需要 mnote-web 已在运行(npm run desktop:hot 或独立启动)
// 检查:新端点 start/send/abort/events、SSE、disabled builtin tools、receipt、no polling
// 检查:新端点 start/send/abort/events、SSE、permission-system managed builtin tools、receipt、no polling
const BASE = process.env.MNOTE_PI_LAB_BASE || 'http://127.0.0.1:3000';
const AUTH_COOKIE = process.env.MNOTE_PI_LAB_AUTH || '';
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || 'mnote-e2e';
async function check(description, fn) {
try {
@@ -24,7 +25,12 @@ async function check(description, fn) {
}
async function fetchJson(url, options = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers };
const headers = {
'Content-Type': 'application/json',
'x-mnote-actor-id': ACTOR_ID,
'x-mnote-actor-type': 'user',
...options.headers,
};
if (AUTH_COOKIE && AUTH_COOKIE.toLowerCase().startsWith('bearer ')) headers.Authorization = AUTH_COOKIE;
else if (AUTH_COOKIE) headers.Cookie = AUTH_COOKIE;
const res = await fetch(url, { ...options, headers });
@@ -59,12 +65,12 @@ async function main() {
return { passed: false, reason: 'missing managedPiSessionDirPolicy' };
}));
// 3. Status response has disabledPiBuiltinTools when enabled
results.push(await check('GET /api/page-ai/pi/status has disabledPiBuiltinTools', async () => {
// 3. Status response has managedPiBuiltinTools when enabled
results.push(await check('GET /api/page-ai/pi/status has managedPiBuiltinTools', async () => {
const { body } = await fetchJson(`${BASE}/api/page-ai/pi/status`);
if (body.enabled === false) return { passed: true }; // skip when disabled
if (body.disabledPiBuiltinTools) return { passed: true };
return { passed: false, reason: 'missing disabledPiBuiltinTools' };
if (Array.isArray(body.managedPiBuiltinTools)) return { passed: true };
return { passed: false, reason: 'missing managedPiBuiltinTools' };
}));
// 4. Start endpoint exists and returns proper schema (may 404 if disabled)
@@ -102,7 +108,11 @@ async function main() {
// 7. Events SSE endpoint returns proper content type
results.push(await check('GET /api/page-ai/pi/events returns SSE stream (disabled may 404)', async () => {
const res = await fetch(`${BASE}/api/page-ai/pi/events`, {
headers: { Accept: 'text/event-stream' },
headers: {
Accept: 'text/event-stream',
'x-mnote-actor-id': ACTOR_ID,
'x-mnote-actor-type': 'user',
},
});
if (res.status === 404 || res.status === 401 || res.status === 403) return { passed: true };
const ct = res.headers.get('Content-Type') || '';
+90 -28
View File
@@ -46,6 +46,12 @@ async function quickLoginIfNeeded(page) {
]);
}
async function approveVisiblePiDialog(page) {
const dialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']");
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-pi-lab-ui-submit]").click();
}
async function main() {
console.log(`\n🧪 Pi Lab browser smoke (base: ${BASE})\n`);
let browserRoot = process.env.MNOTE_PI_LAB_BROWSER_ROOT || "/tmp/mnote-pi-lab-browser-smoke";
@@ -59,6 +65,9 @@ async function main() {
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
await context.addInitScript(() => {
window.__MNOTE_PI_LAB_TEST__ = true;
});
await addAuth(context);
const page = await context.newPage();
@@ -114,10 +123,6 @@ async function main() {
assert(drawerEvidence.toolCount >= 5, `expected MNote tool rail, got ${drawerEvidence.toolCount}`);
console.log(" 4. Independent drawer, context strip and default model verified");
const startButton = page.locator("[data-page-ai-pi-lab-btn-start]");
if (await startButton.isVisible().catch(() => false)) {
await startButton.click();
}
await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, {
timeout: UI_TIMEOUT_MS,
});
@@ -126,6 +131,7 @@ async function main() {
const startStatusData = await startStatusResp.json();
const sessionId = startStatusData.sessionId || startStatusData.session?.sessionId;
assert(sessionId, "UI start should create sessionId");
const workspaceId = startStatusData.session?.workspaceId || startStatusData.workspaceId || undefined;
const firstAllowedRoot = startStatusData.session?.allowedRootsSnapshot?.roots?.[0] || null;
if (firstAllowedRoot?.rootPath) {
browserRoot = String(firstAllowedRoot.rootPath);
@@ -133,29 +139,87 @@ async function main() {
fs.mkdirSync(browserRoot, { recursive: true });
fs.writeFileSync(path.join(browserRoot, pagePath), "# Pi Lab browser smoke\n\nBrowser Original\n", "utf8");
}
const autoEditResp = await page.request.post(`${BASE}/api/page-ai/pi/start`, {
data: {
sessionId,
rootUri,
workspaceId,
pagePath,
pageTitle: "Pi Lab browser smoke",
permissionMode: "auto_edit",
},
});
assert(autoEditResp.ok(), `auto-edit mode start should return HTTP OK, got ${autoEditResp.status()}`);
await page.waitForTimeout(800);
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
await page.waitForFunction(() => {
const button = document.querySelector("[data-page-ai-pi-lab-btn-send]");
return button && !button.disabled;
}, null, { timeout: UI_TIMEOUT_MS });
console.log(" 5. Runtime started and composer is interactive");
console.log(" 5. Runtime auto-started and composer is interactive");
const deniedResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } },
});
assert(deniedResp.ok(), `deny tool call should return HTTP OK, got ${deniedResp.status()}`);
await page.evaluate(() => {
window.__mnotePiLabTest.emitRpcEvent({
type: "extension_ui_request",
id: "browser_smoke_approval",
method: "confirm",
title: "审批 Pi 工具调用",
message: "Browser smoke approval request",
mnoteApproval: {
approvalId: "browser_smoke_approval",
toolName: "mnote.local_file.patch",
paramsHash: "browser-smoke",
},
});
});
const uiResponsePromise = page.waitForResponse(
(res) => res.url().includes("/api/page-ai/pi/ui-response") && res.request().method() === "POST",
{ timeout: UI_TIMEOUT_MS },
);
const approvalBox = await page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']").boundingBox();
const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox();
assert(
approvalBox && inputWrapBox && approvalBox.y + approvalBox.height <= inputWrapBox.y + 1,
"approval dialog should be mounted above the input and must not cover the composer",
);
await approveVisiblePiDialog(page);
const uiResponse = await uiResponsePromise;
assert(uiResponse.ok(), `UI response should return HTTP OK, got ${uiResponse.status()}`);
const uiResponseBody = await uiResponse.json();
assert.equal(uiResponseBody.ok, true, "Pi approval dialog should confirm through composer-local UI");
console.log(" 5b. Composer-local approval dialog verified");
const receiptResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: {
sessionId,
toolName: "mnote.tool_receipt.write",
params: {
diffSummary: "browser smoke synthetic diff",
citations: [{ title: "LightRAG mock citation", source: "lightrag-mock" }],
},
},
});
assert(receiptResp.ok(), `receipt tool call should return HTTP OK, got ${receiptResp.status()}`);
const patchResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: {
sessionId,
toolName: "mnote.local_file.patch",
params: {
path: path.join(browserRoot, pagePath),
rootUri,
path: pagePath,
operations: [{ op: "replace", old: "Browser Original", new: "Browser Patched" }],
},
},
});
assert(patchResp.ok(), `patch tool call should return HTTP OK, got ${patchResp.status()}`);
const patchPayload = await patchResp.json();
assert.equal(patchPayload.ok, true, `patch tool call should be allowed, got ${JSON.stringify(patchPayload)}`);
assert(fs.readFileSync(path.join(browserRoot, pagePath), "utf8").includes("Browser Patched"), "browser smoke patch should update markdown file");
const ragResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: { sessionId, toolName: "mnote.knowledge_rag.query", params: { rootUri, query: "Pi Lab browser smoke", topK: 1 } },
timeout: 8000,
@@ -163,38 +227,35 @@ async function main() {
if (!ragResp.ok()) {
console.warn(` ! LightRAG direct tool call skipped in browser smoke: ${ragResp.status()}`);
}
const receiptResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
data: {
sessionId,
toolName: "mnote.tool_receipt.write",
params: {
citations: [{ title: "LightRAG mock citation", source: "lightrag-mock" }],
},
},
});
assert(receiptResp.ok(), `receipt tool call should return HTTP OK, got ${receiptResp.status()}`);
await page.waitForTimeout(800);
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-receipts]")?.textContent || "";
return text.includes("denied") && text.includes("diff") && text.includes("mnote.tool_receipt.write");
return (text.includes("denied") || text.includes("approval required"))
&& text.includes("diff")
&& text.includes("mnote.local_file.patch")
&& text.includes("mnote.tool_receipt.write");
}, null, { timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-pi-lab-input]").fill("Pi Lab browser smoke prompt");
const replyMarker = `MNOTE_PI_BROWSER_OK_${Date.now()}`;
await page.locator("[data-page-ai-pi-lab-input]").fill(`请只回复 ${replyMarker}`);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
await page.waitForFunction(() => {
const root = document.querySelector('[data-page-ai-pi-lab="drawer"]');
const text = root?.textContent || "";
return text.includes("[Pi Lab mock] prompt accepted") && text.includes("LightRAG mock citation") && text.includes("patch");
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction((marker) => {
return Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]'))
.some((node) => (node.textContent || "").includes(marker));
}, replyMarker, { timeout: Math.max(UI_TIMEOUT_MS, 45000) });
await page.locator("[data-page-ai-pi-lab-btn-abort]").click();
await page.waitForFunction(() => (document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "").includes("aborted"), null, {
timeout: UI_TIMEOUT_MS,
});
const runtimeEvidence = await page.evaluate(() => {
const runtimeEvidence = await page.evaluate((marker) => {
const root = document.querySelector('[data-page-ai-pi-lab="drawer"]');
const text = root?.textContent || "";
const assistantText = Array.from(document.querySelectorAll('[data-page-ai-pi-lab-message-role="assistant"]'))
.map((node) => node.textContent || "")
.join("\n");
return {
hasPromptReply: text.includes("[Pi Lab mock] prompt accepted"),
hasPromptSubmitted: text.includes(marker),
hasPromptReply: assistantText.includes(marker),
hasAborted: text.includes("aborted") || text.includes("已中止"),
hasDeny: text.includes("denied"),
hasReceipt: text.includes("mnote.local_file.patch") || text.includes("mnote.tool_receipt.write"),
@@ -202,15 +263,16 @@ async function main() {
hasDiff: text.includes("patch") || text.includes("diff"),
changedFiles: document.querySelector("[data-page-ai-pi-lab-changed-files]")?.textContent || "",
};
});
assert(runtimeEvidence.hasPromptReply, "Pi Lab should show streamed mock assistant reply");
}, replyMarker);
assert(runtimeEvidence.hasPromptSubmitted, "Pi Lab should show submitted prompt in the real Pi Rust run");
assert(runtimeEvidence.hasPromptReply, "Pi Lab should show a real assistant reply marker");
assert(runtimeEvidence.hasAborted, "Pi Lab should show abort state");
assert(runtimeEvidence.hasDeny, "Pi Lab should show allowed-roots deny receipt");
assert(runtimeEvidence.hasReceipt, "Pi Lab should show tool receipt");
assert(runtimeEvidence.hasCitation, "Pi Lab should show LightRAG citation evidence");
assert(runtimeEvidence.hasDiff, "Pi Lab should show diff/changed file evidence");
assert.notEqual(runtimeEvidence.changedFiles, "0", "changed files chip should be non-zero");
console.log(" 6. Stream, abort, deny, citation, receipt and diff evidence visible");
console.log(" 6. Real Pi Rust send, abort, deny, citation, receipt and diff evidence visible");
const openHubEvidence = await page.evaluate(async () => {
const api = window.__mnoteSidebarPageAiRuntime;
@@ -0,0 +1,356 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_OUT || path.join(os.tmpdir(), `mnote-pi-full-access-ask-user-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "360000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_ROOT_URI || `file://${ROOT_PATH}`;
const MODEL_PROVIDER = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_MODEL_ID || "freefirst";
const MARKER = `PI_FULL_ACCESS_ASK_USER_OK_${STAMP}`;
const PAGE_PATH = `pi-full-access-ask-user-${STAMP}.md`;
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
async function quickLogin(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
await Promise.all([
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
quickLoginButton.click(),
]);
}
async function requestJson(page, url, options = {}) {
const response = await page.request.fetch(`${BASE}${url}`, {
...options,
headers: {
accept: "application/json",
"content-type": "application/json",
...(options.headers || {}),
},
timeout: options.timeout || TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
return body;
}
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
return {
name,
description,
source,
toolNames,
riskLevel,
requiredScopes,
enabled: true,
};
}
function policyForFullAccessAskUser() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.tool_receipt.write": "allow",
},
skills: {},
mcpServers: {},
piExtensions: {
"pi-rust-official-question": piExtensionConfig("Pi Rust Official Question", "Pi Rust 官方索引 question 扩展的本地镜像。", "pi-rust-official:question", ["question"], "low", ["ui:ask"]),
"pi-rust-official-questionnaire": piExtensionConfig("Pi Rust Official Questionnaire", "Pi Rust 官方索引 questionnaire 扩展的本地镜像。", "pi-rust-official:questionnaire", ["questionnaire"], "low", ["ui:ask"]),
},
};
}
async function seedWorkspace(page) {
mkdirp(ROOT_PATH);
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi full access question smoke\n", "utf8");
const grantResponse = await page.request.fetch(`${BASE}/api/admin/access-policy/grants`, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
data: {
userId: ACTOR_ID,
rootUri: ROOT_URI,
rootPath: ROOT_PATH,
permission: "write",
recursive: true,
capabilities: ["ai"],
},
timeout: TIMEOUT,
});
const grantText = await grantResponse.text();
let grantBody = {};
try {
grantBody = grantText ? JSON.parse(grantText) : {};
} catch {
grantBody = { raw: grantText };
}
if (!grantResponse.ok() && grantBody.code !== "local_access_policy_grant_duplicate") {
throw new Error(`POST /api/admin/access-policy/grants failed: ${grantResponse.status()} ${grantText.slice(0, 800)}`);
}
await requestJson(page, "/api/ai-admin/settings", {
method: "PUT",
data: {
...policyForFullAccessAskUser(),
quota: { daily: 200 },
},
});
await requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`);
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status && status.session && status.session.sessionId;
if (!sessionId) return null;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
return sessionId;
}
async function startRealPi(page) {
const sessionId = `pi-full-access-ask-user-${STAMP}`;
const start = await requestJson(page, "/api/page-ai/pi/start", {
method: "POST",
data: {
sessionId,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
pagePath: PAGE_PATH,
pageTitle: "Pi full access question smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel: "medium",
permissionMode: "full_access",
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.permissionMode, "full_access", "start response should expose full_access mode");
assert.equal(start.session.runtimePolicySnapshot.permissionMode, "full_access", "runtime policy should persist full_access");
assert.equal(start.session.runtimeMode, "rpc", `Pi 必须以 rpc 模式启动,实际=${start.session.runtimeMode}`);
assert(start.session.runtimePid, "真实 Pi RPC 启动后应有 runtimePid");
return start.session;
}
async function openPiUi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return /ready|running|streaming/.test(text);
}, null, { timeout: TIMEOUT }).catch(() => null);
}
function readSessionJsonl(sessionDir) {
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(full);
}
}
};
walk(sessionDir);
files.sort();
const sessionFile = files[files.length - 1];
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
}
async function answerVisibleDialog(page, result) {
const dialog = page.locator("[data-page-ai-pi-lab-ui-dialog]").first();
await dialog.waitFor({ state: "visible", timeout: TIMEOUT });
const method = await dialog.getAttribute("data-page-ai-pi-lab-ui-dialog");
if (!result.screenshots.askUserDialog) {
await page.screenshot({ path: path.join(OUT, "02-ask-user-dialog.png"), fullPage: false });
result.screenshots.askUserDialog = path.join(OUT, "02-ask-user-dialog.png");
const dialogBox = await dialog.boundingBox();
const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox();
result.checks.askUserDialogMethod = method;
result.checks.askUserDialogAboveInput = !!dialogBox && !!inputWrapBox && dialogBox.y + dialogBox.height <= inputWrapBox.y + 1;
result.checks.askUserDialogInComposer = await page.locator(".wolai-page-ai-pi-lab-composer [data-page-ai-pi-lab-ui-dialog]").count() > 0;
}
if (method === "select") {
await dialog.locator("[data-page-ai-pi-lab-ui-option]").first().click();
return;
}
if (method === "input" || method === "editor") {
await dialog.locator("[data-page-ai-pi-lab-ui-input]").fill("继续验证");
await dialog.locator("[data-page-ai-pi-lab-ui-submit]").click();
return;
}
if (method === "confirm") {
await dialog.locator("[data-page-ai-pi-lab-ui-submit]").click();
return;
}
if (method === "ask_user" || method === "ask-user" || method === "questionnaire" || method === "custom") {
await dialog.locator("[data-page-ai-pi-lab-ui-option]").first().click();
await dialog.locator("[data-page-ai-pi-lab-ui-submit]").click();
return;
}
throw new Error(`unsupported extension UI dialog method: ${method}`);
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_FULL_ACCESS_ASK_USER_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
const page = await context.newPage();
const consoleMessages = [];
const uiResponses = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
page.on("request", (request) => {
if (request.url().includes("/api/page-ai/pi/ui-response") && request.method() === "POST") {
try {
uiResponses.push(JSON.parse(request.postData() || "{}"));
} catch {
uiResponses.push({ raw: request.postData() || "" });
}
}
});
const result = {
base: BASE,
outputDir: OUT,
marker: MARKER,
rootUri: ROOT_URI,
screenshots: {},
checks: {},
consoleMessages,
uiResponses,
};
try {
await quickLogin(page);
await seedWorkspace(page);
await abortExistingSession(page);
const session = await startRealPi(page);
result.session = {
sessionId: session.sessionId,
runtimePid: session.runtimePid,
piSessionDir: session.piSessionDir,
runtimePolicySnapshot: session.runtimePolicySnapshot,
};
const sources = session.runtimePolicySnapshot.enabledPiExtensionSources || [];
const piExtensionToolNames = session.runtimePolicySnapshot.piExtensionToolNames || [];
result.checks.usesPiRustOfficialQuestion = sources.includes("pi-rust-official:question");
result.checks.usesPiRustOfficialQuestionnaire = sources.includes("pi-rust-official:questionnaire");
result.checks.questionToolAdvertised = piExtensionToolNames.includes("question");
result.checks.questionnaireToolAdvertised = piExtensionToolNames.includes("questionnaire");
result.checks.noLegacyNpmAskUser = !sources.includes("npm:pi-ask-user");
result.checks.retiredAskUserRemoved = !sources.includes("npm:@d3ara1n/pi-ask-user");
assert(result.checks.usesPiRustOfficialQuestion, "runtime should use Pi Rust official question extension");
assert(result.checks.usesPiRustOfficialQuestionnaire, "runtime should use Pi Rust official questionnaire extension");
assert(result.checks.questionToolAdvertised, "runtime policy should advertise question");
assert(result.checks.questionnaireToolAdvertised, "runtime policy should advertise questionnaire");
assert(result.checks.noLegacyNpmAskUser, "runtime should not use unavailable npm:pi-ask-user");
assert(result.checks.retiredAskUserRemoved, "runtime should not use @d3ara1n/pi-ask-user");
await openPiUi(page);
await page.screenshot({ path: path.join(OUT, "01-full-access-ask-user-started.png"), fullPage: false });
result.screenshots.started = path.join(OUT, "01-full-access-ask-user-started.png");
const prompt = [
"这是 MNote Pi Rust official question 真实链路 smoke。",
"你必须调用 question 工具向我提问,不要用普通文本直接问。",
"问题:是否继续验证 MNote Pi 完全访问下的用户问答?",
"提供两个选项:继续验证、停止验证。",
`收到我的回答后,最终单独输出一行:${MARKER}`,
].join("\n");
await page.locator("[data-page-ai-pi-lab-input]").fill(prompt);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const markerLocator = page
.locator('[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, [data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown')
.filter({ hasText: MARKER })
.last();
const startedAt = Date.now();
let answeredDialogs = 0;
while (Date.now() - startedAt < TIMEOUT) {
const permissionVisible = await page.locator("text=Permission Required").count();
assert.equal(permissionVisible, 0, "full_access should not show Permission Required for question");
if (await markerLocator.isVisible({ timeout: 500 }).catch(() => false)) break;
const dialogCount = await page.locator("[data-page-ai-pi-lab-ui-dialog]").count();
if (dialogCount > 0) {
answeredDialogs += 1;
assert(answeredDialogs <= 5, "question smoke should not require more than 5 dialogs");
await answerVisibleDialog(page, result);
}
await page.waitForTimeout(500);
}
await markerLocator.waitFor({ state: "visible", timeout: 1000 });
await page.screenshot({ path: path.join(OUT, "03-ask-user-final-answer.png"), fullPage: false });
result.screenshots.answer = path.join(OUT, "03-ask-user-final-answer.png");
const sessionJsonl = readSessionJsonl(session.piSessionDir);
result.session.sessionFile = sessionJsonl.sessionFile;
result.answerText = ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
result.checks.answeredDialogs = answeredDialogs;
result.checks.questionToolCalled = sessionJsonl.raw.includes("question");
result.checks.extensionUiRequestRecorded = sessionJsonl.raw.includes("extension_ui_request");
result.checks.extensionUiRoundTripObserved = result.checks.answeredDialogs >= 1 && result.answerText.includes(MARKER);
result.checks.noAnswersUndefinedError = !sessionJsonl.raw.includes("Cannot read properties of undefined") && !(await page.locator("text=Cannot read properties of undefined").count());
result.checks.noPermissionRequiredPrompt = await page.locator("text=Permission Required").count() === 0;
assert(result.checks.answeredDialogs >= 1, "question should surface at least one extension UI dialog");
assert(result.checks.questionToolCalled, "Pi session JSONL should record question tool call");
assert(result.checks.extensionUiRoundTripObserved, "question should surface UI, accept an answer, and let Pi continue to final output");
assert(result.checks.askUserDialogInComposer, "question dialog should render inside Pi composer");
assert(result.checks.askUserDialogAboveInput, "question dialog should sit above the input without covering it");
assert(result.checks.noAnswersUndefinedError, "question should not crash with answers undefined");
assert(result.checks.noPermissionRequiredPrompt, "full_access should not show Permission Required prompt");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: session.sessionId } }).catch(() => ({}));
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
}
}
main();
@@ -0,0 +1,291 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_OUT || path.join(os.tmpdir(), `mnote-pi-full-access-builtin-disabled-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "360000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const ROOT_PATH = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_ROOT_URI || `file://${ROOT_PATH}`;
const MODEL_PROVIDER = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_MODEL_ID || "freefirst";
const MARKER = `PI_FULL_ACCESS_BUILTIN_DISABLED_OK_${STAMP}`;
const PAGE_PATH = `pi-full-access-builtin-disabled-${STAMP}.md`;
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
async function quickLogin(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
await Promise.all([
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
quickLoginButton.click(),
]);
}
async function requestJson(page, url, options = {}) {
const response = await page.request.fetch(`${BASE}${url}`, {
...options,
headers: {
accept: "application/json",
"content-type": "application/json",
...(options.headers || {}),
},
timeout: options.timeout || TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
return body;
}
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
return { name, description, source, toolNames, riskLevel, requiredScopes, enabled: true };
}
function policyForFullAccessBuiltinDisabled() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.tool_receipt.write": "allow",
},
skills: {},
mcpServers: {},
piExtensions: {
"pi-rust-official-question": piExtensionConfig("Pi Rust Official Question", "Pi Rust 官方索引 question 扩展的本地镜像。", "pi-rust-official:question", ["question"], "low", ["ui:ask"]),
"pi-rust-official-questionnaire": piExtensionConfig("Pi Rust Official Questionnaire", "Pi Rust 官方索引 questionnaire 扩展的本地镜像。", "pi-rust-official:questionnaire", ["questionnaire"], "low", ["ui:ask"]),
"pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方索引 permission-gate 扩展的本地镜像。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]),
},
};
}
async function seedWorkspace(page) {
mkdirp(ROOT_PATH);
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi full access builtin disabled smoke\n", "utf8");
const grantResponse = await page.request.fetch(`${BASE}/api/admin/access-policy/grants`, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
data: {
userId: ACTOR_ID,
rootUri: ROOT_URI,
rootPath: ROOT_PATH,
permission: "write",
recursive: true,
capabilities: ["ai"],
},
timeout: TIMEOUT,
});
const grantText = await grantResponse.text();
let grantBody = {};
try {
grantBody = grantText ? JSON.parse(grantText) : {};
} catch {
grantBody = { raw: grantText };
}
if (!grantResponse.ok() && grantBody.code !== "local_access_policy_grant_duplicate") {
throw new Error(`POST /api/admin/access-policy/grants failed: ${grantResponse.status()} ${grantText.slice(0, 800)}`);
}
await requestJson(page, "/api/ai-admin/settings", {
method: "PUT",
data: {
...policyForFullAccessBuiltinDisabled(),
quota: { daily: 200 },
},
});
await requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`);
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status && status.session && status.session.sessionId;
if (!sessionId) return null;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
return sessionId;
}
async function startRealPi(page) {
const sessionId = `pi-full-access-builtin-disabled-${STAMP}`;
const start = await requestJson(page, "/api/page-ai/pi/start", {
method: "POST",
data: {
sessionId,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
pagePath: PAGE_PATH,
pageTitle: "Pi full access builtin disabled smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel: "medium",
permissionMode: "full_access",
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.permissionMode, "full_access", "start response should expose full_access mode");
assert.equal(start.session.runtimePolicySnapshot.permissionMode, "full_access", "runtime policy should persist full_access");
assert.equal(start.session.runtimeMode, "rpc", `Pi 必须以 rpc 模式启动,实际=${start.session.runtimeMode}`);
assert(start.session.runtimePid, "真实 Pi RPC 启动后应有 runtimePid");
return start.session;
}
async function openPiUi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return /ready|running|streaming/.test(text);
}, null, { timeout: TIMEOUT }).catch(() => null);
}
function readSessionJsonl(sessionDir) {
const files = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(full);
}
}
};
walk(sessionDir);
files.sort();
const sessionFile = files[files.length - 1];
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_FULL_ACCESS_BUILTIN_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
const page = await context.newPage();
const consoleMessages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const result = {
base: BASE,
outputDir: OUT,
marker: MARKER,
rootUri: ROOT_URI,
screenshots: {},
checks: {},
consoleMessages,
};
try {
await quickLogin(page);
await seedWorkspace(page);
await abortExistingSession(page);
const session = await startRealPi(page);
result.session = {
sessionId: session.sessionId,
runtimePid: session.runtimePid,
piSessionDir: session.piSessionDir,
runtimePolicySnapshot: session.runtimePolicySnapshot,
};
const enabledSources = session.runtimePolicySnapshot.enabledPiExtensionSources || [];
const permissionConfigPath = path.join(session.piSessionDir, "config", "extensions", "pi-permission-system", "config.json");
result.permissionConfigPath = permissionConfigPath;
result.checks.permissionSystemConfigAbsent = !fs.existsSync(permissionConfigPath);
result.checks.noLegacyNpmAskUser = !enabledSources.includes("npm:pi-ask-user");
result.checks.noExternalPermissionSystem = !enabledSources.includes("npm:@gotgenes/pi-permission-system");
result.checks.officialPermissionGateConfigured = enabledSources.includes("pi-rust-official:permission-gate");
result.checks.managedBuiltinToolsDisabledAtStart = (session.runtimePolicySnapshot.managedBuiltinTools || []).length === 0;
assert.equal(result.checks.permissionSystemConfigAbsent, true, "full_access smoke should not generate legacy pi-permission-system config");
assert.equal(result.checks.noLegacyNpmAskUser, true, "full_access smoke should not use unavailable npm:pi-ask-user");
assert.equal(result.checks.noExternalPermissionSystem, true, "full_access smoke should not load incompatible pi-permission-system");
assert.equal(result.checks.officialPermissionGateConfigured, true, "policy should include Pi Rust official permission-gate");
await openPiUi(page);
await page.screenshot({ path: path.join(OUT, "01-full-access-started.png"), fullPage: false });
result.screenshots.started = path.join(OUT, "01-full-access-started.png");
const prompt = [
"请调用 mnote_allowed_roots_describe 工具,读取 MNote 返回的 allowedRoots、deniedPiBuiltinTools、managedPiBuiltinTools、permissionProvider。",
"不要调用 bash/read/write/edit/hashline_edit/grep/find/ls 这些 Pi 内置工具。",
"用一句话说明:full_access 下 MNote 当前仍默认禁用 Pi Rust 内置文件/命令工具,文件权限由 MNote bridge 管控。",
`最终单独输出一行:${MARKER}`,
].join("\n");
await page.locator("[data-page-ai-pi-lab-input]").fill(prompt);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const markerLocator = page
.locator('[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, [data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown')
.filter({ hasText: MARKER })
.last();
const startedAt = Date.now();
while (Date.now() - startedAt < TIMEOUT) {
const permissionVisible = await page.locator("text=Permission Required").count();
assert.equal(permissionVisible, 0, "official permission-gate smoke should not ask legacy Permission Required dialog");
if (await markerLocator.isVisible({ timeout: 500 }).catch(() => false)) break;
await page.waitForTimeout(500);
}
await markerLocator.waitFor({ state: "visible", timeout: 1000 });
await page.screenshot({ path: path.join(OUT, "02-full-access-builtin-disabled-answer.png"), fullPage: false });
result.screenshots.answer = path.join(OUT, "02-full-access-builtin-disabled-answer.png");
const sessionJsonl = readSessionJsonl(session.piSessionDir);
result.session.sessionFile = sessionJsonl.sessionFile;
result.answerText = ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
result.checks.allowedRootsToolCalled = /mnote_allowed_roots_describe/.test(sessionJsonl.raw);
result.checks.deniedBuiltinsRecorded = /deniedPiBuiltinTools/.test(sessionJsonl.raw) && /hashline_edit/.test(sessionJsonl.raw);
result.checks.managedBuiltinsEmptyRecorded = /managedPiBuiltinTools/.test(sessionJsonl.raw);
result.checks.noRawBuiltinCalled = !/"name":"(bash|read|write|edit|hashline_edit|grep|find|ls)"/.test(sessionJsonl.raw);
result.checks.noPermissionRequiredPrompt = await page.locator("text=Permission Required").count() === 0;
assert(result.checks.allowedRootsToolCalled, "Pi session JSONL should record mnote_allowed_roots_describe call");
assert(result.checks.deniedBuiltinsRecorded, "Pi session JSONL should include deniedPiBuiltinTools");
assert(result.checks.managedBuiltinsEmptyRecorded, "Pi session JSONL should include managedPiBuiltinTools");
assert(result.checks.noRawBuiltinCalled, "Pi raw builtin tools should remain disabled by default");
assert(result.checks.noPermissionRequiredPrompt, "official permission-gate smoke should not show legacy Permission Required prompt");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: session.sessionId } }).catch(() => ({}));
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
}
}
main();
+521
View File
@@ -0,0 +1,521 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const {
setupWorkspaceAccess,
seedAiPolicy,
} = require("./lib/control-plane-dev-seed");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_INPUT_CONTROLS_OUT || path.join(os.tmpdir(), `mnote-pi-input-controls-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "240000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_PI_INPUT_WORKSPACE_ID || `local-ws:${ACTOR_ID}:pi-input-controls`;
const ROOT_PATH = process.env.MNOTE_PI_INPUT_ROOT_PATH || path.join(OUT, "workspace");
const ROOT_URI = process.env.MNOTE_PI_INPUT_ROOT_URI || `file://${ROOT_PATH}`;
const PAGE_DIR = `pi-input-controls-${STAMP}`;
const PAGE_PATH = `${PAGE_DIR}/pi-input-controls-${STAMP}.md`;
const ROOT_PAGE_PATH = `pi-input-controls-root-${STAMP}.md`;
const MODEL_PROVIDER = process.env.MNOTE_PI_INPUT_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_INPUT_MODEL_ID || "freefirst";
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
async function quickLogin(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
await Promise.all([
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
quickLoginButton.click(),
]);
}
async function requestJson(page, url, options = {}) {
const response = await page.request.fetch(`${BASE}${url}`, {
...options,
headers: {
accept: "application/json",
"content-type": "application/json",
...(options.headers || {}),
},
timeout: options.timeout || TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
return body;
}
function policyForInputControls() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.selection.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.local_file.read": "allow",
"mnote.local_file.patch": "ask",
"mnote.knowledge_rag.status": "allow",
"mnote.knowledge_rag.query": "allow",
"mnote.knowledge_rag.section_context": "allow",
"mnote.knowledge_rag.open_reference": "allow",
"mnote.reference.open": "allow",
"mnote.tool_receipt.write": "allow",
"mnote.codex_rescue.request": "ask",
},
skills: {},
mcpServers: {},
};
}
async function seedWorkspace(page) {
mkdirp(ROOT_PATH);
mkdirp(path.dirname(path.join(ROOT_PATH, PAGE_PATH)));
fs.writeFileSync(
path.join(ROOT_PATH, PAGE_PATH),
[
"# Pi input controls smoke",
"",
"CURRENT_PAGE_QUICK_CONTENT_OK",
"This file proves current-page quick read used a real allowed root.",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(
path.join(ROOT_PATH, ROOT_PAGE_PATH),
[
"# Pi input controls root page",
"",
"ROOT_PAGE_FOLDER_CONTEXT_OK",
"",
].join("\n"),
"utf8",
);
await setupWorkspaceAccess(page.request, BASE, {
actorId: ACTOR_ID,
email: "mnote.e2e@example.com",
username: ACTOR_ID,
displayName: ACTOR_ID,
role: "admin",
workspaceId: WORKSPACE_ID,
workspaceName: "Pi input controls smoke",
rootPath: ROOT_PATH,
rootUri: ROOT_URI,
permission: "write",
capabilities: ["ai", "read", "write"],
timeoutMs: TIMEOUT,
});
await seedAiPolicy(page.request, BASE, {
id: `pi-input-controls-policy-${ACTOR_ID}-${WORKSPACE_ID}`,
userId: ACTOR_ID,
workspaceId: WORKSPACE_ID,
allowedRootsJson: [{ rootUri: ROOT_URI, rootPath: ROOT_PATH, permission: "write" }],
modelPolicyJson: policyForInputControls(),
quotaJson: { daily: 200 },
timeoutMs: TIMEOUT,
});
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status && status.session && status.session.sessionId;
if (!sessionId) return;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
}
async function startSession(page, sessionId, thinkingLevel, pagePath = PAGE_PATH) {
const start = await requestJson(page, "/api/page-ai/pi/start", {
method: "POST",
data: {
sessionId,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
pagePath,
pageTitle: "Pi input controls smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel,
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.session.thinkingLevel, thinkingLevel, "start should persist requested thinkingLevel");
return start.session;
}
async function openPiUi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
}
async function emit(page, payload) {
await page.evaluate((eventPayload) => {
window.__mnotePiLabTest.emitRpcEvent(eventPayload);
}, payload);
}
async function composerValue(page) {
return page.locator("[data-page-ai-pi-lab-input]").inputValue();
}
async function clickQuickAndAssertComposer(page, kind, pattern, label) {
await page.locator(`[data-page-ai-pi-lab-quick="${kind}"]`).click();
await page.waitForFunction(
({ selector, source }) => new RegExp(source).test(document.querySelector(selector)?.value || ""),
{ selector: "[data-page-ai-pi-lab-input]", source: pattern.source },
{ timeout: TIMEOUT },
);
const value = await composerValue(page);
assert(pattern.test(value), `${label} did not update composer: ${value.slice(0, 300)}`);
}
async function clickQuickAndAssertActive(page, kind, expectedActive, label) {
const before = await composerValue(page);
const requestSeen = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/tool-call") || request.url().includes("/api/page-ai/pi/send"),
{ timeout: 900 },
).then((request) => request.url()).catch(() => null);
await page.locator(`[data-page-ai-pi-lab-quick="${kind}"]`).click();
await page.waitForFunction(
({ kind: targetKind, expected }) => document.querySelector(`[data-page-ai-pi-lab-quick="${targetKind}"]`)?.getAttribute("data-active") === String(expected),
{ kind, expected: expectedActive },
{ timeout: TIMEOUT },
);
const after = await composerValue(page);
const unexpectedRequest = await requestSeen;
assert.equal(after, before, `${label} should toggle context state without changing composer`);
assert.equal(unexpectedRequest, null, `${label} should not send or call a tool on click: ${unexpectedRequest}`);
}
async function clickMenuContextAndAssertActive(page, action, expectedActive, label) {
const before = await composerValue(page);
const requestSeen = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/tool-call") || request.url().includes("/api/page-ai/pi/send"),
{ timeout: 900 },
).then((request) => request.url()).catch(() => null);
await openActionMenu(page);
await page.locator(`[data-page-ai-pi-lab-menu-action="${action}"]`).click();
await page.waitForFunction(
({ action: targetAction, expected }) => document.querySelector(`[data-page-ai-pi-lab-menu-action="${targetAction}"]`)?.getAttribute("data-active") === String(expected),
{ action, expected: expectedActive },
{ timeout: TIMEOUT },
);
const after = await composerValue(page);
const unexpectedRequest = await requestSeen;
assert.equal(after, before, `${label} should toggle context state without changing composer`);
assert.equal(unexpectedRequest, null, `${label} should not send or call a tool on click: ${unexpectedRequest}`);
}
async function quickActive(page, kind) {
return page.locator(`[data-page-ai-pi-lab-quick="${kind}"]`).getAttribute("data-active");
}
async function openActionMenu(page) {
const toggle = page.locator("[data-page-ai-pi-lab-action-menu-toggle]");
await toggle.click();
const menu = page.locator("[data-page-ai-pi-lab-action-menu]");
await menu.waitFor({ state: "visible", timeout: TIMEOUT });
return menu;
}
async function sendViaMenuAndCapture(page, action, text) {
await page.locator("[data-page-ai-pi-lab-input]").fill(text);
await emit(page, { type: "message_update", assistantMessageEvent: { type: "text_start" } });
const requestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
{ timeout: TIMEOUT },
);
await openActionMenu(page);
await page.locator(`[data-page-ai-pi-lab-menu-action="${action}"]`).click();
const request = await requestPromise;
return request.postDataJSON();
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_INPUT_CONTROLS_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
await context.addInitScript(() => {
window.__MNOTE_PI_LAB_TEST__ = true;
});
const page = await context.newPage();
const consoleMessages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const result = {
base: BASE,
outputDir: OUT,
rootUri: ROOT_URI,
pagePath: PAGE_PATH,
screenshots: {},
checks: {},
consoleMessages,
};
try {
await quickLogin(page);
await seedWorkspace(page);
await abortExistingSession(page);
const session = await startSession(page, `pi-input-controls-${STAMP}`, "high");
result.session = {
sessionId: session.sessionId,
runtimeMode: session.runtimeMode,
thinkingLevel: session.thinkingLevel,
};
await openPiUi(page);
result.checks.thinkingInitialValue = await page.locator("[data-page-ai-pi-lab-thinking]").inputValue();
assert.equal(result.checks.thinkingInitialValue, "high", "thinking selector should reflect current session");
result.checks.permissionLabel = (await page.locator("[data-page-ai-pi-lab-permission-label]").textContent() || "").trim();
assert(/确认|审批|受限|自动/.test(result.checks.permissionLabel), `permission label missing: ${result.checks.permissionLabel}`);
await page.locator("[data-page-ai-pi-lab-permission]").click();
await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.permissionMenuVisible = true;
result.checks.permissionModes = await page.locator("[data-page-ai-pi-lab-permission-mode]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-permission-mode")));
assert.deepEqual(result.checks.permissionModes, ["confirm", "auto_edit", "plan", "full_access"]);
const modeStartRequestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
{ timeout: TIMEOUT },
);
await page.locator('[data-page-ai-pi-lab-permission-mode="auto_edit"]').click();
const modeStartBody = (await modeStartRequestPromise).postDataJSON();
result.checks.permissionAutoEditStartMode = modeStartBody.permissionMode;
result.checks.permissionAutoEditStartSessionId = modeStartBody.sessionId;
assert.equal(modeStartBody.permissionMode, "auto_edit", "mode switch should apply through /api/page-ai/pi/start");
assert.equal(modeStartBody.sessionId, session.sessionId, "mode switch should keep the current Pi session");
await page.waitForFunction(() => /自动编辑/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
result.checks.permissionAutoEditSelected = true;
result.checks.noRestartRuntimeHint = await page.locator('[title*="重启 Pi runtime"]').count() === 0;
assert.equal(result.checks.noRestartRuntimeHint, true, "Pi controls should not ask the user to restart Pi runtime");
await page.screenshot({ path: path.join(OUT, "00-permission-mode-auto-applied.png"), fullPage: false });
result.screenshots.permissionModeAutoApplied = path.join(OUT, "00-permission-mode-auto-applied.png");
await page.locator("[data-page-ai-pi-lab-permission]").click();
await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
const fullAccessStartRequestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
{ timeout: TIMEOUT },
);
await page.locator('[data-page-ai-pi-lab-permission-mode="full_access"]').click();
const fullAccessStartBody = (await fullAccessStartRequestPromise).postDataJSON();
result.checks.permissionFullAccessStartMode = fullAccessStartBody.permissionMode;
result.checks.permissionFullAccessStartSessionId = fullAccessStartBody.sessionId;
assert.equal(fullAccessStartBody.permissionMode, "full_access", "full access mode should apply through /api/page-ai/pi/start");
assert.equal(fullAccessStartBody.sessionId, session.sessionId, "full access switch should keep the current Pi session");
await page.waitForFunction(() => /完全访问/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
const currentPageTool = await requestJson(page, "/api/page-ai/pi/tool-call", {
method: "POST",
data: {
sessionId: session.sessionId,
toolName: "mnote.current_page.read",
params: { rootUri: ROOT_URI, pagePath: PAGE_PATH },
},
});
result.checks.fullAccessCurrentPageToolOk = currentPageTool.ok;
result.checks.fullAccessCurrentPageApprovalRequired = currentPageTool.approvalRequired;
result.checks.fullAccessCurrentPageContent = currentPageTool.result && currentPageTool.result.content;
assert.equal(currentPageTool.ok, true, "full access should allow current page read without MNote approval");
assert.equal(currentPageTool.approvalRequired, false, "full access should not require approval for current page read");
assert.match(String(currentPageTool.result && currentPageTool.result.content || ""), /CURRENT_PAGE_QUICK_CONTENT_OK/, "current page read should return seeded page content");
await page.waitForTimeout(600);
result.checks.noPermissionRequiredPromptInFullAccess = await page.locator("text=Permission Required").count() === 0;
assert.equal(result.checks.noPermissionRequiredPromptInFullAccess, true, "full access should not show Permission Required prompt for current page read");
await page.screenshot({ path: path.join(OUT, "00b-permission-full-access-current-page-no-prompt.png"), fullPage: false });
result.screenshots.permissionFullAccessNoPrompt = path.join(OUT, "00b-permission-full-access-current-page-no-prompt.png");
result.checks.currentPageInitialActive = await page.locator('[data-page-ai-pi-lab-quick="read-page"]').getAttribute("data-active");
assert.equal(result.checks.currentPageInitialActive, "false", "no context should be selected by default");
await clickQuickAndAssertActive(page, "read-page", true, "bottom current-page context on");
result.checks.bottomCurrentPageOn = true;
await clickQuickAndAssertActive(page, "read-page", false, "bottom current-page context off");
result.checks.bottomCurrentPageOff = true;
await clickQuickAndAssertActive(page, "current-folder", true, "bottom current-folder context on without page");
result.checks.folderOnDoesNotRestorePage = await quickActive(page, "read-page");
assert.equal(result.checks.folderOnDoesNotRestorePage, "false", "folder on should not restore current page");
await clickQuickAndAssertActive(page, "current-folder", false, "bottom current-folder context off without page");
result.checks.folderOffDoesNotRestorePage = await quickActive(page, "read-page");
assert.equal(result.checks.folderOffDoesNotRestorePage, "false", "folder off should not restore current page");
await startSession(page, `pi-input-controls-root-${STAMP}`, "high", ROOT_PAGE_PATH);
await openPiUi(page);
const rootFolderToastPromise = page.locator("text=当前页没有可用文件夹上下文").waitFor({ state: "visible", timeout: 900 }).catch(() => null);
await clickQuickAndAssertActive(page, "current-folder", true, "bottom current-folder context on root page");
result.checks.rootPageCurrentFolderActive = await quickActive(page, "current-folder");
result.checks.rootPageFolderUnavailableToast = Boolean(await rootFolderToastPromise);
assert.equal(result.checks.rootPageCurrentFolderActive, "true", "root-level page should allow workspace root folder context");
assert.equal(result.checks.rootPageFolderUnavailableToast, false, "root-level page should not warn about missing folder context");
await clickQuickAndAssertActive(page, "current-folder", false, "bottom current-folder context off root page");
await startSession(page, session.sessionId, "high");
await openPiUi(page);
await clickQuickAndAssertActive(page, "read-page", true, "bottom current-page context on again");
result.checks.bottomCurrentPageOnAgain = true;
await clickQuickAndAssertActive(page, "current-folder", true, "bottom current-folder context");
result.checks.bottomCurrentFolder = true;
await clickQuickAndAssertActive(page, "selection", true, "bottom selection context");
result.checks.bottomSelection = true;
await clickQuickAndAssertActive(page, "rag", true, "bottom LightRAG context");
result.checks.bottomRag = true;
await openActionMenu(page);
result.checks.attachDisabled = await page.locator('[data-page-ai-pi-lab-menu-action="attach-file"]').isDisabled();
result.checks.cameraDisabled = await page.locator('[data-page-ai-pi-lab-menu-action="camera"]').isDisabled();
result.checks.planModeAvailable = await page.locator('[data-page-ai-pi-lab-menu-action="plan-mode"]:not(:disabled)', { hasText: "计划评审" }).count() === 1;
await page.locator('[data-page-ai-pi-lab-menu-action="plan-mode"]').click();
await page.waitForFunction(() => /\/plan|plan-mode|计划评审/.test(document.querySelector("[data-page-ai-pi-lab-input]")?.value || ""), null, { timeout: TIMEOUT });
result.checks.planModeComposer = await composerValue(page);
await clickMenuContextAndAssertActive(page, "rag", false, "menu LightRAG context off");
result.checks.menuRagOff = true;
await clickMenuContextAndAssertActive(page, "rag", true, "menu LightRAG context on");
result.checks.menuRagOn = true;
await clickMenuContextAndAssertActive(page, "selection", false, "menu selection context off");
result.checks.menuSelectionOff = true;
await clickMenuContextAndAssertActive(page, "selection", true, "menu selection context on");
result.checks.menuSelectionOn = true;
await clickMenuContextAndAssertActive(page, "current-folder", false, "menu current-folder context off");
result.checks.menuCurrentFolderOff = true;
await clickMenuContextAndAssertActive(page, "current-folder", true, "menu current-folder context on");
result.checks.menuCurrentFolderOn = true;
await clickMenuContextAndAssertActive(page, "read-page", false, "menu current-page context off");
result.checks.menuReadPageOff = true;
await clickMenuContextAndAssertActive(page, "read-page", true, "menu current-page context on");
result.checks.menuReadPageOn = true;
await page.screenshot({ path: path.join(OUT, "01-context-actions-working.png"), fullPage: false });
result.screenshots.contextActions = path.join(OUT, "01-context-actions-working.png");
const steerBody = await sendViaMenuAndCapture(page, "send-steer", "steer input controls smoke");
result.checks.menuSteerStreamingBehavior = steerBody.streamingBehavior;
assert.equal(steerBody.streamingBehavior, "steer", "menu steer should send streamingBehavior=steer");
const followBody = await sendViaMenuAndCapture(page, "send-followup", "follow-up input controls smoke");
result.checks.menuFollowupStreamingBehavior = followBody.streamingBehavior;
assert.equal(followBody.streamingBehavior, "followUp", "menu follow-up should send streamingBehavior=followUp");
await page.screenshot({ path: path.join(OUT, "02-streaming-send-menu-working.png"), fullPage: false });
result.screenshots.streamingSendMenu = path.join(OUT, "02-streaming-send-menu-working.png");
await page.locator("[data-page-ai-pi-lab-history]").click();
await page.locator(`[data-page-ai-pi-lab-history-row="${session.sessionId}"]`).waitFor({ state: "visible", timeout: TIMEOUT });
await page.locator(`[data-page-ai-pi-lab-history-session="${session.sessionId}"]`).first().click();
await page.waitForFunction(
(expectedSessionId) => document.querySelector(`[data-page-ai-pi-lab-history-row="${expectedSessionId}"]`)?.getAttribute("data-active") === "true",
session.sessionId,
{ timeout: TIMEOUT },
);
result.checks.historyInputDisabled = await page.locator("[data-page-ai-pi-lab-input]").isDisabled();
result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible().catch(() => false);
assert.equal(result.checks.historyInputDisabled, false, "opening history should keep composer editable");
assert.equal(result.checks.historyReplayBannerVisible, false, "opening history should not show readonly replay banner");
const historyStartRequestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
{ timeout: TIMEOUT },
);
const historySendRequestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
{ timeout: TIMEOUT },
);
await page.locator("[data-page-ai-pi-lab-input]").fill("history continue input controls smoke");
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const historyStartBody = (await historyStartRequestPromise).postDataJSON();
const historySendBody = (await historySendRequestPromise).postDataJSON();
result.checks.historyContinueStartSessionId = historyStartBody.sessionId;
result.checks.historyContinueSendSessionId = historySendBody.sessionId;
result.checks.historyContinueMessage = historySendBody.message;
assert.equal(historyStartBody.sessionId, session.sessionId, "continuing history should restart the opened session");
assert.equal(historySendBody.sessionId, session.sessionId, "continuing history should send to the opened session");
assert.equal(historySendBody.message, "history continue input controls smoke", "history continue should send composer text");
await page.screenshot({ path: path.join(OUT, "03-history-session-continues.png"), fullPage: false });
result.screenshots.historySessionContinues = path.join(OUT, "03-history-session-continues.png");
await page.locator("[data-page-ai-pi-lab-new]").click();
await page.locator("[data-page-ai-pi-lab-thinking]").selectOption("xhigh");
const startRequestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/start") && request.method() === "POST",
{ timeout: TIMEOUT },
);
await page.locator("[data-page-ai-pi-lab-input]").fill("auto start thinking level smoke");
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const startRequest = await startRequestPromise;
const startBody = startRequest.postDataJSON();
result.checks.startThinkingLevel = startBody.thinkingLevel;
assert.equal(startBody.thinkingLevel, "xhigh", "thinking selector should be sent on start");
const sendRequestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
{ timeout: TIMEOUT },
);
await page.locator("[data-page-ai-pi-lab-input]").fill("send button input controls smoke");
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const sendRequest = await sendRequestPromise;
const sendBody = sendRequest.postDataJSON();
result.checks.sendButtonMessage = sendBody.message;
result.checks.sendButtonRootUri = sendBody.rootUri;
result.checks.sendButtonPagePath = sendBody.pagePath;
result.checks.sendButtonFolderPath = sendBody.folderPath;
result.checks.sendButtonContextRefs = sendBody.contextRefs;
result.checks.sendButtonSelectedContext = sendBody.selectedContext;
assert.equal(sendBody.message, "send button input controls smoke", "send button should post composer text");
assert.equal(sendBody.rootUri, ROOT_URI, "send should refresh Pi session rootUri from current page context");
assert.equal(sendBody.pagePath, PAGE_PATH, "send should refresh Pi session pagePath from current page context");
assert.equal(sendBody.folderPath, PAGE_DIR, "send should include selected current-folder path");
assert.deepEqual(sendBody.contextRefs, ["current_page", "folder", "selection", "lightrag"], "send should include selected context refs");
assert.equal(sendBody.selectedContext.currentPage.pagePath, PAGE_PATH, "selectedContext should include current page address");
assert.equal(sendBody.selectedContext.currentFolder.folderPath, PAGE_DIR, "selectedContext should include current folder address");
assert.equal(sendBody.selectedContext.lightrag.enabled, true, "selectedContext should include LightRAG toggle");
await openActionMenu(page);
await Promise.all([
page.waitForURL((url) => url.pathname === "/user/ai" && url.hash === "#ai-admin-access", { timeout: TIMEOUT }),
page.locator('[data-page-ai-pi-lab-menu-action="directory-permission"]').click(),
]);
await page.locator("#ai-admin-access.is-active").waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.directoryPermissionUrl = page.url();
await page.screenshot({ path: path.join(OUT, "04-directory-permission-entry.png"), fullPage: false });
result.screenshots.directoryPermission = path.join(OUT, "04-directory-permission-entry.png");
assert(result.checks.attachDisabled, "attach-file should stay disabled until attachment context is implemented");
assert(result.checks.cameraDisabled, "camera should stay disabled until attachment context is implemented");
assert(result.checks.planModeAvailable, "plan review should be available through Pi Rust official plan-mode extension");
assert(/\/plan|plan-mode|计划评审/.test(result.checks.planModeComposer || ""), "plan review action should write a Pi Rust official /plan command");
assert.equal(consoleMessages.length, 0, `console errors: ${consoleMessages.join("\n")}`);
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
}
}
main();
+17 -6
View File
@@ -12,6 +12,7 @@ const path = require("node:path");
const BASE = process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3000";
const AUTH = process.env.MNOTE_PI_LAB_AUTH || "Bearer pi-lab-smoke";
const ROOT = process.env.MNOTE_PI_LAB_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-lab-"));
const DIRECT_TOOL_CALL_APPROVAL = process.env.MNOTE_PI_LAB_DIRECT_TOOL_APPROVAL === "1";
function assert(condition, message) {
if (!condition) throw new Error(message);
@@ -111,7 +112,8 @@ async function main() {
assert(denied.status === 200, `deny read returned ${denied.status}`);
assert(denied.body.ok === false, "out-of-root read should be denied");
assert(denied.body.receipt, "denied read should still write receipt");
console.log(" ✅ out-of-root read denied with receipt");
assert(denied.body.approvalRequired === true, "direct local file read should require approval before path checks");
console.log(" ✅ out-of-root read blocked by ask policy with receipt");
const patch = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
@@ -125,11 +127,20 @@ async function main() {
},
}),
});
assert(patch.status === 200 && patch.body.ok === true, "local file patch failed");
assert(patch.body.result.polling === false, "patch must not request polling");
assert(String(patch.body.result.refresh || "").includes("watcher"), "patch must declare watcher refresh");
assert(fs.readFileSync(pageFile, "utf8").includes("Patched body"), "patched file content mismatch");
console.log(" ✅ local file patch + watcher refresh metadata");
if (DIRECT_TOOL_CALL_APPROVAL) {
assert(patch.status === 200 && patch.body.ok === true, "local file patch failed");
assert(patch.body.result.polling === false, "patch must not request polling");
assert(String(patch.body.result.refresh || "").includes("watcher"), "patch must declare watcher refresh");
assert(fs.readFileSync(pageFile, "utf8").includes("Patched body"), "patched file content mismatch");
console.log(" ✅ local file patch + watcher refresh metadata");
} else {
assert(patch.status === 200, `patch returned ${patch.status}`);
assert(patch.body.ok === false, "direct local file patch should require UI/bridge approval");
assert(patch.body.approvalRequired === true, "patch should expose approvalRequired");
assert(String(patch.body.result?.code || "") === "page_ai_pi_lab_tool_approval_required", "patch should be blocked by ask policy");
assert(fs.readFileSync(pageFile, "utf8").includes("Original body"), "unapproved patch must not modify file");
console.log(" ✅ direct local file patch blocked by ask policy");
}
const rag = await fetchJson(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
@@ -0,0 +1,385 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_PLAN_MODE_OUT || path.join(os.tmpdir(), `mnote-pi-plan-mode-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "240000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_PI_PLAN_MODE_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const ROOT_PATH = process.env.MNOTE_PI_PLAN_MODE_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_PI_PLAN_MODE_ROOT_URI || `file://${ROOT_PATH}`;
const MODEL_PROVIDER = process.env.MNOTE_PI_PLAN_MODE_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_PLAN_MODE_MODEL_ID || "freefirst";
const PAGE_PATH = `pi-plan-mode-${STAMP}.md`;
const TARGET_PATH = `pi-plan-mode-target-${STAMP}.md`;
const TARGET_ABS = path.join(ROOT_PATH, TARGET_PATH);
const TARGET_ORIGINAL = `PLAN_MODE_TARGET_ORIGINAL_${STAMP}\n`;
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
async function quickLogin(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
await Promise.all([
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
quickLoginButton.click(),
]);
}
async function requestJson(page, url, options = {}) {
const response = await page.request.fetch(`${BASE}${url}`, {
...options,
headers: {
accept: "application/json",
"content-type": "application/json",
...(options.headers || {}),
},
timeout: options.timeout || TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
if (options.allowStatus) return { response, body, text };
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
return body;
}
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
return {
name,
description,
source,
toolNames,
riskLevel,
requiredScopes,
enabled: true,
};
}
function messageTextsFromSessionJsonl(raw) {
return raw
.split(/\n+/)
.filter(Boolean)
.flatMap((line) => {
try {
const event = JSON.parse(line);
const content = event && event.message && Array.isArray(event.message.content)
? event.message.content
: [];
return content
.filter((item) => item && item.type === "text" && typeof item.text === "string")
.map((item) => item.text);
} catch {
return [];
}
});
}
function policyForPlanMode() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.selection.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.local_file.read": "allow",
"mnote.local_file.patch": "ask",
"mnote.knowledge_rag.status": "allow",
"mnote.knowledge_rag.query": "allow",
"mnote.knowledge_rag.section_context": "allow",
"mnote.knowledge_rag.open_reference": "allow",
"mnote.reference.open": "allow",
"mnote.tool_receipt.write": "allow",
"mnote.codex_rescue.request": "ask",
},
skills: {},
mcpServers: {},
piExtensions: {
"pi-rust-official-question": piExtensionConfig(
"Pi Rust Official Question",
"Pi Rust 官方索引 question 扩展的本地镜像。",
"pi-rust-official:question",
["question"],
"low",
["ui:ask"],
),
"pi-rust-official-questionnaire": piExtensionConfig(
"Pi Rust Official Questionnaire",
"Pi Rust 官方索引 questionnaire 扩展的本地镜像。",
"pi-rust-official:questionnaire",
["questionnaire"],
"low",
["ui:ask"],
),
},
};
}
async function seedWorkspace(page) {
mkdirp(ROOT_PATH);
fs.writeFileSync(path.join(ROOT_PATH, PAGE_PATH), "# Pi plan mode browser smoke\n", "utf8");
fs.writeFileSync(TARGET_ABS, TARGET_ORIGINAL, "utf8");
const grantResponse = await page.request.fetch(`${BASE}/api/admin/access-policy/grants`, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
data: {
userId: ACTOR_ID,
rootUri: ROOT_URI,
rootPath: ROOT_PATH,
permission: "write",
recursive: true,
capabilities: ["ai"],
},
timeout: TIMEOUT,
});
const grantText = await grantResponse.text();
let grantBody = {};
try {
grantBody = grantText ? JSON.parse(grantText) : {};
} catch {
grantBody = { raw: grantText };
}
if (!grantResponse.ok() && grantBody.code !== "local_access_policy_grant_duplicate") {
throw new Error(`POST /api/admin/access-policy/grants failed: ${grantResponse.status()} ${grantText.slice(0, 800)}`);
}
await requestJson(page, "/api/ai-admin/settings", {
method: "PUT",
data: {
...policyForPlanMode(),
quota: { daily: 200 },
},
});
await requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`);
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status && status.session && status.session.sessionId;
if (!sessionId) return null;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
return sessionId;
}
async function startPlanSession(page) {
const sessionId = `pi-plan-mode-${STAMP}`;
const start = await requestJson(page, "/api/page-ai/pi/start", {
method: "POST",
data: {
sessionId,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
pagePath: PAGE_PATH,
pageTitle: "Pi plan mode browser smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel: "off",
permissionMode: "plan",
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.permissionMode, "plan", "start response should expose plan mode");
assert.equal(start.session.runtimePolicySnapshot.permissionMode, "plan", "runtime policy should persist plan mode");
return start.session;
}
function assertPlanPermissionConfig(session, result) {
const configPath = path.join(session.piSessionDir, "config", "extensions", "pi-permission-system", "config.json");
result.permissionConfigPath = configPath;
result.checks.permissionSystemConfigAbsent = !fs.existsSync(configPath);
result.checks.planRuntimePolicyMode = session.runtimePolicySnapshot.permissionMode;
result.checks.planQuestionSource = (session.runtimePolicySnapshot.enabledPiExtensionSources || []).includes("pi-rust-official:question");
result.checks.planQuestionnaireSource = (session.runtimePolicySnapshot.enabledPiExtensionSources || []).includes("pi-rust-official:questionnaire");
result.checks.noLegacyNpmAskUser = !(session.runtimePolicySnapshot.enabledPiExtensionSources || []).includes("npm:pi-ask-user");
result.checks.noExternalPermissionSystem = !(session.runtimePolicySnapshot.enabledPiExtensionSources || []).includes("npm:@gotgenes/pi-permission-system");
assert.equal(result.checks.permissionSystemConfigAbsent, true, "Pi Rust plan smoke should not generate legacy pi-permission-system config");
assert.equal(result.checks.planRuntimePolicyMode, "plan", "plan mode should persist in runtime policy");
assert.equal(result.checks.planQuestionSource, true, "plan smoke should use Pi Rust official question extension");
assert.equal(result.checks.planQuestionnaireSource, true, "plan smoke should use Pi Rust official questionnaire extension");
assert.equal(result.checks.noLegacyNpmAskUser, true, "plan smoke should not use unavailable npm:pi-ask-user");
assert.equal(result.checks.noExternalPermissionSystem, true, "plan smoke should not load incompatible pi-permission-system");
}
async function readLatestSessionJsonl(sessionDir, timeoutMs = 120000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const files = fs.readdirSync(sessionDir)
.filter((name) => name.endsWith(".jsonl"))
.map((name) => path.join(sessionDir, name))
.sort();
const sessionFile = files[files.length - 1];
if (sessionFile) {
const raw = fs.readFileSync(sessionFile, "utf8");
if (raw.includes("当前是 MNote Pi 计划模式")) {
return { sessionFile, raw };
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
const files = fs.readdirSync(sessionDir)
.filter((name) => name.endsWith(".jsonl"))
.map((name) => path.join(sessionDir, name))
.sort();
const sessionFile = files[files.length - 1];
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
}
async function openPiUi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => /计划模式/.test(document.querySelector("[data-page-ai-pi-lab-permission-label]")?.textContent || ""), null, { timeout: TIMEOUT });
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_PLAN_MODE_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
const page = await context.newPage();
const consoleMessages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const result = {
base: BASE,
outputDir: OUT,
rootUri: ROOT_URI,
targetPath: TARGET_PATH,
targetAbs: TARGET_ABS,
screenshots: {},
checks: {},
consoleMessages,
};
try {
await quickLogin(page);
await seedWorkspace(page);
await abortExistingSession(page);
const session = await startPlanSession(page);
result.session = {
sessionId: session.sessionId,
runtimeMode: session.runtimeMode,
runtimePid: session.runtimePid,
piSessionDir: session.piSessionDir,
runtimePolicySnapshot: session.runtimePolicySnapshot,
};
assertPlanPermissionConfig(session, result);
await openPiUi(page);
await page.locator("[data-page-ai-pi-lab-permission]").click();
await page.locator("[data-page-ai-pi-lab-permission-menu]").waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.permissionModes = await page.locator("[data-page-ai-pi-lab-permission-mode]").evaluateAll((nodes) => nodes.map((node) => ({
mode: node.getAttribute("data-page-ai-pi-lab-permission-mode"),
text: node.textContent.trim(),
active: node.getAttribute("data-active"),
})));
assert.deepEqual(result.checks.permissionModes.map((item) => item.mode), ["confirm", "auto_edit", "plan", "full_access"]);
assert.equal(result.checks.permissionModes.find((item) => item.mode === "plan").active, "true", "plan menu item should be active");
await page.screenshot({ path: path.join(OUT, "01-plan-mode-menu-active.png"), fullPage: false });
result.screenshots.planModeMenu = path.join(OUT, "01-plan-mode-menu-active.png");
await page.keyboard.press("Escape").catch(() => null);
const patchAttempt = await requestJson(page, "/api/page-ai/pi/tool-call", {
method: "POST",
allowStatus: true,
data: {
sessionId: session.sessionId,
toolName: "mnote.local_file.patch",
params: {
rootUri: ROOT_URI,
path: TARGET_PATH,
operations: [{ op: "replace", content: "PLAN_MODE_SHOULD_NOT_WRITE\n" }],
},
},
});
result.checks.planPatchHttpStatus = patchAttempt.response.status();
result.checks.planPatchBody = patchAttempt.body;
assert.equal(patchAttempt.response.ok(), false, "plan mode should reject local_file.patch before approval");
assert.equal(patchAttempt.body.code, "page_ai_pi_lab_tool_denied_by_permission_mode", "plan mode should deny patch tool by policy");
const prompt = [
"这是计划模式浏览器 smoke。",
`请不要真正修改文件,只分析如果要修改 ${TARGET_PATH} 应该怎么做。`,
"如果当前是计划模式,请说明需要切换到自动编辑或完全访问后才能执行写入。",
].join("\n");
const sendRequestPromise = page.waitForRequest(
(request) => request.url().includes("/api/page-ai/pi/send") && request.method() === "POST",
{ timeout: TIMEOUT },
);
const sendResponsePromise = page.waitForResponse(
(response) => response.url().includes("/api/page-ai/pi/send") && response.request().method() === "POST",
{ timeout: TIMEOUT },
);
await page.locator("[data-page-ai-pi-lab-input]").fill(prompt);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const sendBody = (await sendRequestPromise).postDataJSON();
const sendResponse = await sendResponsePromise;
const sendPayload = await sendResponse.json();
result.checks.planSendOriginalMessage = sendBody.message;
result.checks.planSendPermissionMode = sendPayload.permissionMode;
result.checks.planModePromptApplied = sendPayload.planModePromptApplied;
assert.equal(sendBody.message, prompt, "UI should send the user's original text");
assert.equal(sendPayload.permissionMode, "plan", "send response should remain in plan mode");
assert.equal(sendPayload.planModePromptApplied, true, "backend should apply plan-mode readonly prompt wrapper");
const sessionJsonl = await readLatestSessionJsonl(session.piSessionDir);
result.session.sessionFile = sessionJsonl.sessionFile;
const sessionTexts = messageTextsFromSessionJsonl(sessionJsonl.raw);
result.checks.rpcCommandContainsPlanWrapper = sessionTexts.some((text) => text.includes("当前是 MNote Pi 计划模式"));
result.checks.rpcCommandPreservesOriginalPrompt = sessionTexts.some((text) => text.includes(prompt));
assert.equal(result.checks.rpcCommandContainsPlanWrapper, true, "RPC command should contain the readonly plan-mode wrapper");
assert.equal(result.checks.rpcCommandPreservesOriginalPrompt, true, "RPC command should include the user's original prompt inside the plan-mode wrapper");
await page.waitForTimeout(2000);
result.checks.targetContentAfterPlan = fs.readFileSync(TARGET_ABS, "utf8");
result.checks.noPermissionRequiredPrompt = await page.locator("text=Permission Required").count() === 0;
assert.equal(result.checks.targetContentAfterPlan, TARGET_ORIGINAL, "plan mode should not mutate the target file");
assert.equal(result.checks.noPermissionRequiredPrompt, true, "plan mode denial should not show an ask prompt");
await page.screenshot({ path: path.join(OUT, "02-plan-mode-send-applied.png"), fullPage: false });
result.screenshots.planModeSend = path.join(OUT, "02-plan-mode-send-applied.png");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: session.sessionId } }).catch(() => null);
assert.equal(consoleMessages.length, 0, `console errors: ${consoleMessages.join("\n")}`);
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
}
}
main();
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_REAL_LIGHTRAG_OUT || path.join(os.tmpdir(), `mnote-pi-real-lightrag-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "420000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`;
const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "freefirst";
const MARKER = `PI_REAL_LIGHTRAG_CARBOXY_OK_${STAMP}`;
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
async function quickLogin(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
await Promise.all([
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
quickLoginButton.click(),
]);
}
async function requestJson(page, url, options = {}) {
const response = await page.request.fetch(`${BASE}${url}`, {
...options,
headers: {
accept: "application/json",
"content-type": "application/json",
...(options.headers || {}),
},
timeout: options.timeout || TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
return body;
}
function policyForLightRag() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.selection.read": "allow",
"mnote.allowed_roots.describe": "allow",
"mnote.knowledge_rag.status": "allow",
"mnote.knowledge_rag.query": "allow",
"mnote.knowledge_rag.section_context": "allow",
"mnote.knowledge_rag.open_reference": "allow",
"mnote.reference.open": "allow",
"mnote.tool_receipt.write": "allow",
},
skills: {},
mcpServers: {},
};
}
async function ensureDirectoryGrant(page) {
const response = await page.request.fetch(`${BASE}/api/admin/access-policy/grants`, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
data: {
userId: ACTOR_ID,
rootUri: ROOT_URI,
rootPath: ROOT_PATH,
permission: "write",
recursive: true,
capabilities: ["ai"],
},
timeout: TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
if (response.ok()) return body;
if (body && body.code === "local_access_policy_grant_duplicate") return body;
throw new Error(`POST /api/admin/access-policy/grants failed: ${response.status()} ${text.slice(0, 800)}`);
}
async function seedRuntimePolicy(page) {
mkdirp(ROOT_PATH);
await ensureDirectoryGrant(page);
await requestJson(page, "/api/ai-admin/settings", {
method: "PUT",
data: {
...policyForLightRag(),
quota: { daily: 200 },
},
});
}
async function ensureLightRagReady(page) {
const status = await requestJson(
page,
`/api/knowledge-rag/status?workspaceId=${encodeURIComponent(WORKSPACE_ID)}&rootUri=${encodeURIComponent(ROOT_URI)}`,
);
const legacyHealthOk = status && status.legacyHealth && status.legacyHealth.ok === true;
const documentsOk = status && status.documents && status.documents.ok === true;
assert(
legacyHealthOk && documentsOk,
`LightRAG provider is not ready: ${JSON.stringify({
legacyHealth: status && status.legacyHealth,
documents: status && status.documents,
}).slice(0, 1000)}`,
);
return status;
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status && status.session && status.session.sessionId;
if (!sessionId) return null;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
return sessionId;
}
async function startRealPi(page) {
const sessionId = `pi-real-lightrag-${STAMP}`;
const pagePath = `__pi_real_lightrag_${STAMP}.md`;
fs.writeFileSync(path.join(ROOT_PATH, pagePath), "# Pi real LightRAG smoke\n", "utf8");
const start = await requestJson(page, "/api/page-ai/pi/start", {
method: "POST",
data: {
sessionId,
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
pagePath,
pageTitle: "Pi real LightRAG smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel: "off",
permissionMode: "full_access",
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.session.runtimeMode, "rpc", `Pi 必须以 rpc 模式启动,实际=${start.session.runtimeMode}`);
assert(start.session.runtimePid, "真实 Pi RPC 启动后应有 runtimePid");
return start.session;
}
async function openPiUi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return /ready|running|streaming/.test(text);
}, null, { timeout: TIMEOUT }).catch(() => null);
}
async function expandToolTimelines(page) {
const timelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all();
for (const timeline of timelines) {
await timeline.evaluate((node) => { node.open = true; }).catch(() => {});
}
}
function readSessionJsonl(sessionDir) {
const files = fs.readdirSync(sessionDir)
.filter((name) => name.endsWith(".jsonl"))
.map((name) => path.join(sessionDir, name))
.sort();
const sessionFile = files[files.length - 1];
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
}
function extractAssistantText(text) {
return text
.replace(/\s+/g, " ")
.trim();
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_REAL_LIGHTRAG_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
const page = await context.newPage();
const consoleMessages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const result = {
base: BASE,
outputDir: OUT,
marker: MARKER,
screenshots: {},
checks: {},
session: {},
consoleMessages,
};
try {
await quickLogin(page);
await seedRuntimePolicy(page);
result.lightRagStatus = await ensureLightRagReady(page);
result.seededEffective = await requestJson(page, `/api/ai-settings/effective?workspaceId=${encodeURIComponent(WORKSPACE_ID)}`);
result.checks.lightRagToolsEnabled = [
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
].every((toolName) => (result.seededEffective.toolCatalog || []).some((tool) => tool.name === toolName && tool.defaultPolicy !== "deny"));
assert(result.checks.lightRagToolsEnabled, "mnote-e2e 未获得 LightRAG 工具权限");
await abortExistingSession(page);
const session = await startRealPi(page);
result.session = {
sessionId: session.sessionId,
runtimePid: session.runtimePid,
piSessionDir: session.piSessionDir,
runtimePolicySnapshot: session.runtimePolicySnapshot,
};
result.checks.runtimeLightRagTools = [
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
].every((toolName) => (session.runtimePolicySnapshot.mnoteToolNames || []).includes(toolName));
assert(result.checks.runtimeLightRagTools, "Pi runtime policy 未包含 LightRAG MNote tools");
await openPiUi(page);
await page.screenshot({ path: path.join(OUT, "01-real-pi-lightrag-started.png"), fullPage: false });
result.screenshots.started = path.join(OUT, "01-real-pi-lightrag-started.png");
const prompt = [
"必须真实调用 MNote LightRAG 工具 mnote_knowledge_rag_query,不能只凭记忆回答。",
"问题:羧酸的保护基有哪些?请列举 5 种,并为每种给出来自资料库的引用依据。",
"调用参数建议:query=羧酸的保护基有哪些?列举5种并给出引用;mode=naivetopK=50chunkTopK=20includeChunkContent=trueincludeDocumentStructureIndex=true。",
"最终回答用中文,列出 5 条。每条都要包含保护基/酯类型、资料库原文短引或文献编号。",
`最后必须单独输出一行:${MARKER}`,
].join("\n");
const input = page.locator("[data-page-ai-pi-lab-input]");
await input.fill(prompt);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const markerLocator = page
.locator('[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, [data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown')
.filter({ hasText: MARKER })
.last();
await markerLocator.waitFor({ state: "visible", timeout: TIMEOUT });
await expandToolTimelines(page);
const toolTimeline = page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").first();
await toolTimeline.scrollIntoViewIfNeeded({ timeout: TIMEOUT }).catch(() => null);
await page.screenshot({ path: path.join(OUT, "02-real-pi-lightrag-tool-card.png"), fullPage: false });
result.screenshots.toolCard = path.join(OUT, "02-real-pi-lightrag-tool-card.png");
await markerLocator.scrollIntoViewIfNeeded({ timeout: TIMEOUT }).catch(() => null);
await page.screenshot({ path: path.join(OUT, "02-real-pi-lightrag-answer.png"), fullPage: false });
result.screenshots.answer = path.join(OUT, "02-real-pi-lightrag-answer.png");
const assistantText = extractAssistantText((await markerLocator.textContent({ timeout: TIMEOUT })) || "");
result.answerText = assistantText;
const toolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n");
const sessionJsonl = readSessionJsonl(session.piSessionDir);
result.session.sessionFile = sessionJsonl.sessionFile;
result.checks.toolCardVisible = /LightRAG|knowledge_rag|mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/i.test(toolText);
result.checks.queryToolCalled = /mnote_knowledge_rag_query|mnote\.knowledge_rag\.query/.test(sessionJsonl.raw);
result.checks.referencesReturned = /references|citations|citationMarkdown|displayQuote/.test(sessionJsonl.raw);
result.checks.answerHasFiveItems = (assistantText.match(/(^|\s)([1-5]([.、.]|️⃣)|[-*]\s)/g) || []).length >= 5 || /5\s*种|五种|5\s*種|五種/.test(assistantText);
result.checks.answerMentionsEvidence = /(\[[0-9]{3,4}\]|苯甲酰溴甲酯|碳酸二|硫酸二甲酯|磷酸三甲酯|引用|原文)/.test(assistantText);
result.checks.noReasoningLeakInToolTimeline = !/reasoning_content|\"thinking\"/i.test(toolText);
assert(result.checks.toolCardVisible, "UI 未显示 LightRAG/MNote RAG 工具卡");
assert(result.checks.queryToolCalled, "Pi session JSONL 未记录 LightRAG 查询工具调用");
assert(result.checks.referencesReturned, "Pi session JSONL 未包含 LightRAG references/citations");
assert(result.checks.answerHasFiveItems, "Pi 最终回答未明显列出 5 项");
assert(result.checks.answerMentionsEvidence, "Pi 最终回答未明显包含引用依据");
assert(result.checks.noReasoningLeakInToolTimeline, "工具时间线不应泄漏 thinking/reasoning 字段");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: session.sessionId } }).catch(() => ({}));
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
}
}
main();
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_REAL_SKILL_MCP_OUT || path.join(os.tmpdir(), `mnote-pi-real-skill-mcp-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "360000", 10);
const ACTOR_ID = process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const WORKSPACE_ID = process.env.MNOTE_E2E_WORKSPACE_ID || "local-ws:mnote-e2e:my-space";
const ROOT_PATH = process.env.MNOTE_E2E_ROOT_PATH || "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = process.env.MNOTE_E2E_ROOT_URI || `file://${ROOT_PATH}`;
const MODEL_PROVIDER = process.env.MNOTE_PI_REAL_MODEL_PROVIDER || "omniroute";
const MODEL_ID = process.env.MNOTE_PI_REAL_MODEL_ID || "freefirst";
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
const MARKERS = {
vpn: `PI_REAL_VPN_SKILL_OK_${STAMP}`,
context7: `PI_REAL_CONTEXT7_SKILL_MCP_OK_${STAMP}`,
mempalace: `PI_REAL_MEMPALACE_MCP_OK_${STAMP}`,
};
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
async function quickLogin(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
await Promise.all([
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
quickLoginButton.click(),
]);
}
async function requestJson(page, url, options = {}) {
const response = await page.request.fetch(`${BASE}${url}`, {
...options,
headers: {
accept: "application/json",
"content-type": "application/json",
...(options.headers || {}),
},
timeout: options.timeout || TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
assert(response.ok(), `${options.method || "GET"} ${url} failed: ${response.status()} ${text.slice(0, 800)}`);
return body;
}
function skillConfig(name, description, source, riskLevel, requiredScopes = []) {
return { name, description, source, riskLevel, requiredScopes, enabled: true };
}
function mcpConfig(name, description, transport, command, url, networkPolicy, secretRefs, riskLevel, requiredScopes = []) {
return {
name,
description,
transport,
command,
url,
networkPolicy,
secretRefs,
riskLevel,
requiredScopes,
enabled: true,
facadeOnly: true,
sandbox: true,
};
}
function piExtensionConfig(name, description, source, toolNames, riskLevel, requiredScopes = []) {
return {
name,
description,
source,
toolNames,
riskLevel,
requiredScopes,
enabled: true,
};
}
function policyForAllSkillsAndMcp() {
return {
defaultModel: `${MODEL_PROVIDER}/${MODEL_ID}`,
allowedModels: [`${MODEL_PROVIDER}/${MODEL_ID}`],
tools: {
"mnote.current_page.read": "allow",
"mnote.local_file.read": "ask",
"mnote.local_file.patch": "ask",
"mnote.tool_receipt.write": "allow",
},
skills: {
vpn: skillConfig("VPN", "通过 MNote facade 协助诊断代理、出海访问和本机网络路由问题。", "/home/lix/.codex/skills/vpn/SKILL.md", "high", ["network:diagnose", "admin:network"]),
"chrome-bridge": skillConfig("Chrome Bridge", "通过受控浏览器桥接执行页面验证、截图和 DOM/网络诊断。", "mcp://chrome-bridge", "high", ["browser:automation", "qa:browser"]),
context7: skillConfig("Context7", "查询最新官方库文档、API 参数和发布说明。", "/home/lix/.codex/skills/context7/SKILL.md", "medium", ["network:docs"]),
searxng: skillConfig("SearXNG Search", "通过本地 SearXNG MCP 做通用网页检索并保留引用。", "mcp://searxng", "medium", ["network:search"]),
"global-search": skillConfig("Global Search", "聚合本机/网页搜索线索,适合研究型查询入口。", "/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md", "medium", ["network:search"]),
mempalace: skillConfig("MemPalace", "读取共享记忆与历史决策事实层,默认通过 MCP facade 受控访问。", "mcp://mempalace", "medium", ["memory:read"]),
codegraph: skillConfig("CodeGraph", "读取项目代码图、符号和调用关系,适合开发者工作区。", "mcp://codegraph", "medium", ["workspace:code-read"]),
},
mcpServers: {
"chrome-bridge": mcpConfig("Chrome Bridge", "本机 Chromium/Chrome 桥接,用于浏览器 QA、截图与网络请求核验。", "stdio", "node /home/lix/apps/codex-chrome-bridge/mcp/chrome-bridge-mcp.mjs", "", "allow-local", [], "high", ["browser:automation", "qa:browser"]),
context7: mcpConfig("Context7", "官方文档检索 MCP。", "streamable-http", "", "https://mcp.context7.com/mcp", "allow-all", ["env://CONTEXT7_API_KEY"], "medium", ["network:docs"]),
searxng: mcpConfig("SearXNG", "本地 SearXNG 检索 MCP。", "stdio", "node /home/lix/.agent-infra/searxng/codex-searxng-mcp.cjs", "", "allow-local", [], "medium", ["network:search"]),
mempalace: mcpConfig("MemPalace", "共享记忆事实层 MCP。", "stdio", "/home/lix/.local/share/uv/tools/mempalace/bin/python -m mempalace.mcp_server --palace /home/lix/.mempalace/palace", "", "deny-all", [], "medium", ["memory:read"]),
codegraph: mcpConfig("CodeGraph", "代码图 MCP。", "stdio", "codegraph serve --mcp", "", "deny-all", [], "medium", ["workspace:code-read"]),
},
piExtensions: {
"pi-rust-official-question": piExtensionConfig("Pi Rust Official Question", "Pi Rust 官方索引 question 扩展的本地镜像。", "pi-rust-official:question", ["question"], "low", ["ui:ask"]),
"pi-rust-official-questionnaire": piExtensionConfig("Pi Rust Official Questionnaire", "Pi Rust 官方索引 questionnaire 扩展的本地镜像。", "pi-rust-official:questionnaire", ["questionnaire"], "low", ["ui:ask"]),
"pi-rust-official-todo": piExtensionConfig("Pi Rust Official Todo", "Pi Rust 官方索引 todo 扩展的本地镜像。", "pi-rust-official:todo", ["todo"], "medium", ["workflow:todo"]),
"pi-rust-official-permission-gate": piExtensionConfig("Pi Rust Official Permission Gate", "Pi Rust 官方索引 permission-gate 扩展的本地镜像。", "pi-rust-official:permission-gate", [], "high", ["tool:policy"]),
"pi-rust-official-plan-mode": piExtensionConfig("Pi Rust Official Plan Mode", "Pi Rust 官方索引 plan-mode 扩展的本地镜像。", "pi-rust-official:plan-mode", [], "medium", ["workflow:plan-review"]),
"pi-rust-official-subagent": piExtensionConfig("Pi Rust Official Subagent", "Pi Rust 官方索引 subagent 扩展的本地镜像。", "pi-rust-official:subagent", ["subagent"], "high", ["agent:delegate"]),
},
};
}
async function ensureDirectoryGrant(page) {
const response = await page.request.fetch(`${BASE}/api/admin/access-policy/grants`, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
data: {
userId: ACTOR_ID,
rootUri: ROOT_URI,
rootPath: ROOT_PATH,
permission: "write",
recursive: true,
capabilities: ["ai"],
},
timeout: TIMEOUT,
});
const text = await response.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = { raw: text };
}
if (response.ok()) return body;
if (body && body.code === "local_access_policy_grant_duplicate") return body;
throw new Error(`POST /api/admin/access-policy/grants failed: ${response.status()} ${text.slice(0, 800)}`);
}
async function seedRuntimePolicy(page) {
mkdirp(ROOT_PATH);
await ensureDirectoryGrant(page);
const policy = policyForAllSkillsAndMcp();
await requestJson(page, "/api/ai-admin/settings", {
method: "PUT",
data: {
...policy,
quota: { daily: 200 },
},
});
}
async function abortExistingSession(page) {
const status = await requestJson(page, "/api/page-ai/pi/status").catch(() => null);
const sessionId = status && status.session && status.session.sessionId;
if (!sessionId) return null;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId } }).catch(() => null);
return sessionId;
}
async function startRealPi(page, label) {
const safeLabel = String(label || "check").replace(/[^a-z0-9_-]+/gi, "-");
const sessionId = `pi-real-skill-mcp-${safeLabel}-${STAMP}`;
const pagePath = `__pi_real_skill_mcp_${safeLabel}_${STAMP}.md`;
fs.writeFileSync(path.join(ROOT_PATH, pagePath), "# Pi real skill MCP smoke\n", "utf8");
const start = await requestJson(page, "/api/page-ai/pi/start", {
method: "POST",
data: {
sessionId,
rootUri: ROOT_URI,
pagePath,
pageTitle: "Pi real skill MCP smoke",
modelProvider: MODEL_PROVIDER,
modelId: MODEL_ID,
thinkingLevel: "off",
permissionMode: "full_access",
},
});
assert.equal(start.ok, true, "Pi start ok should be true");
assert.equal(start.session.runtimeMode, "rpc", `Pi 必须以 rpc 模式启动,实际=${start.session.runtimeMode}`);
assert(start.session.runtimePid, "真实 Pi RPC 启动后应有 runtimePid");
return start.session;
}
async function openPiUi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return /ready|running|streaming/.test(text);
}, null, { timeout: TIMEOUT }).catch(() => null);
}
async function sendPrompt(page, prompt, marker, screenshotPath) {
const input = page.locator("[data-page-ai-pi-lab-input]");
await input.fill(prompt);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const markerLocator = page
.locator('[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, [data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown')
.filter({ hasText: marker })
.last();
const startedAt = Date.now();
while (Date.now() - startedAt < TIMEOUT) {
if (await markerLocator.isVisible({ timeout: 500 }).catch(() => false)) break;
await approvePendingPermissionDialog(page);
await page.waitForTimeout(500);
}
await markerLocator.waitFor({ state: "visible", timeout: 1000 });
await page.waitForFunction(() => {
const status = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return status === "ready";
}, null, { timeout: 30000 });
await expandToolTimelines(page);
await page.screenshot({ path: screenshotPath, fullPage: false });
return ((await markerLocator.textContent({ timeout: TIMEOUT })) || "").trim();
}
async function approvePendingPermissionDialog(page) {
const dialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']");
if (!(await dialog.isVisible({ timeout: 250 }).catch(() => false))) return false;
const preferred = dialog
.getByRole("button")
.filter({ hasText: /allow .* session|本会话|Yes, allow/i })
.first();
if (await preferred.isVisible({ timeout: 250 }).catch(() => false)) {
await preferred.click();
return true;
}
const yes = dialog.getByRole("button", { name: /^Yes$/i }).first();
if (await yes.isVisible({ timeout: 250 }).catch(() => false)) {
await yes.click();
return true;
}
const submit = page.locator("[data-page-ai-pi-lab-ui-submit]").first();
if (await submit.isVisible({ timeout: 250 }).catch(() => false)) {
await submit.click();
return true;
}
return false;
}
async function expandToolTimelines(page) {
const timelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all();
for (const timeline of timelines) {
await timeline.evaluate((node) => { node.open = true; }).catch(() => {});
}
}
function readSessionJsonl(sessionDir) {
const files = [];
const visit = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) visit(target);
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(target);
}
};
visit(sessionDir);
files.sort();
const sessionFile = files[files.length - 1];
assert(sessionFile, `session jsonl not found in ${sessionDir}`);
return { sessionFile, raw: fs.readFileSync(sessionFile, "utf8") };
}
function findFilesByName(root, fileName) {
const matches = [];
const visit = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const target = path.join(dir, entry.name);
if (entry.isDirectory()) visit(target);
else if (entry.isFile() && entry.name === fileName) matches.push(target);
}
};
visit(root);
return matches;
}
function readMcpToolResults(raw) {
return raw
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return undefined;
}
})
.filter((entry) => entry?.type === "message"
&& entry?.message?.role === "toolResult"
&& entry?.message?.toolName === "mcp")
.map((entry) => entry.message);
}
function inspectRuntimeSession(session) {
const mcpConfigPath = path.join(session.piSessionDir, "config", "mcp.json");
const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, "utf8"));
const runtimeExtensionsPath = path.join(session.piSessionDir, "config", "runtime-extensions");
const clientPaths = findFilesByName(runtimeExtensionsPath, "client.mjs");
assert.equal(clientPaths.length, 1, "session 应仅生成一个 Pi Rust MCP client");
const clientPath = clientPaths[0];
const extensionDir = path.dirname(clientPath);
const stagedMcpConfigPath = path.join(extensionDir, ".pi", "mcp.json");
assert(fs.existsSync(stagedMcpConfigPath), "Pi Rust MCP client 相邻目录缺少 .pi/mcp.json");
const privateBridgePaths = findFilesByName(runtimeExtensionsPath, "mnote-bridge.json");
assert.equal(privateBridgePaths.length, 0, "不应继续生成 MCP private backend bridge config");
return {
sessionId: session.sessionId,
runtimePid: session.runtimePid,
piSessionDir: session.piSessionDir,
runtimePolicySnapshot: session.runtimePolicySnapshot,
mcpConfigPath,
mcpConfig,
syncClient: {
clientPath,
stagedMcpConfigPath,
clientAdjacent: path.dirname(stagedMcpConfigPath) === path.join(extensionDir, ".pi"),
configMatches: fs.readFileSync(stagedMcpConfigPath, "utf8") === fs.readFileSync(mcpConfigPath, "utf8"),
noPrivateBridgeConfig: privateBridgePaths.length === 0,
},
};
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_REAL_SKILL_MCP_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
const page = await context.newPage();
const consoleMessages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const result = {
base: BASE,
outputDir: OUT,
markers: MARKERS,
screenshots: {},
checks: {},
session: {},
sessions: {},
consoleMessages,
};
try {
await quickLogin(page);
await seedRuntimePolicy(page);
result.seededEffective = await requestJson(page, "/api/ai-settings/effective");
result.checks.allSkillsEnabled = ["VPN", "Chrome Bridge", "Context7", "SearXNG Search", "Global Search", "MemPalace", "CodeGraph"]
.every((name) => (result.seededEffective.skills || []).some((skill) => skill.name === name && skill.enabled !== false));
result.checks.allMcpEnabled = ["Chrome Bridge", "Context7", "SearXNG", "MemPalace", "CodeGraph"]
.every((name) => (result.seededEffective.mcpServers || []).some((server) => server.name === name && server.enabled !== false));
assert(result.checks.allSkillsEnabled, "mnote-e2e 未获得全部 Skill 权限");
assert(result.checks.allMcpEnabled, "mnote-e2e 未获得全部 MCP 权限");
await abortExistingSession(page);
const context7Session = await startRealPi(page, "context7");
result.sessions.context7 = inspectRuntimeSession(context7Session);
result.session = result.sessions.context7;
assert.equal(context7Session.runtimePolicySnapshot.mcpBridge, "pi-rust-sync-client", "MNote 内置 Pi Rust MCP sync client 应默认启用");
result.checks.context7SkillCliSource = (context7Session.runtimePolicySnapshot.enabledSkillSources || []).includes("/home/lix/.codex/skills/context7/SKILL.md");
result.checks.vpnSkillCliSource = (context7Session.runtimePolicySnapshot.enabledSkillSources || []).includes("/home/lix/.codex/skills/vpn/SKILL.md");
result.checks.context7McpConfigured = !!result.sessions.context7.mcpConfig.mcpServers?.context7;
result.checks.mempalaceMcpConfigured = !!result.sessions.context7.mcpConfig.mcpServers?.mempalace;
assert(result.checks.context7SkillCliSource, "runtime policy 未包含 Context7 skill source");
assert(result.checks.vpnSkillCliSource, "runtime policy 未包含 VPN skill source");
assert(result.checks.context7McpConfigured, "session mcp.json 未包含 Context7 MCP");
assert(result.checks.mempalaceMcpConfigured, "session mcp.json 未包含 MemPalace MCP");
result.checks.mcpSyncClientAdjacent = result.sessions.context7.syncClient.clientAdjacent;
result.checks.mcpSyncClientConfigMatches = result.sessions.context7.syncClient.configMatches;
result.checks.noPrivateBridgeConfig = result.sessions.context7.syncClient.noPrivateBridgeConfig;
assert(result.checks.mcpSyncClientAdjacent, "MCP client 与 session .pi/mcp.json 未相邻部署");
assert(result.checks.mcpSyncClientConfigMatches, "MCP client 相邻配置与 session mcp.json 不一致");
assert(result.checks.noPrivateBridgeConfig, "仍生成了已废弃的 MCP private backend bridge config");
await openPiUi(page);
await page.screenshot({ path: path.join(OUT, "01-real-pi-started.png"), fullPage: false });
result.screenshots.started = path.join(OUT, "01-real-pi-started.png");
result.checks.context7Reply = await sendPrompt(page, [
"必须真实调用 MCP 工具,不能只凭记忆回答。",
"调用 mcp({server:\"context7\",mode:\"list\"}) 查看可用工具。",
"回答中必须写出至少一个实际返回的 Context7 工具名。",
`最后必须单独输出一行:${MARKERS.context7}`,
].join("\n"), MARKERS.context7, path.join(OUT, "02-context7-mcp.png"));
result.screenshots.context7 = path.join(OUT, "02-context7-mcp.png");
result.checks.context7ReturnedActualTool = /resolve-library-id|query-docs/i.test(result.checks.context7Reply);
assert(result.checks.context7ReturnedActualTool, "Context7 网页回复未包含真实返回的工具名");
const context7ToolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n");
const context7Jsonl = readSessionJsonl(context7Session.piSessionDir);
const context7ToolResults = readMcpToolResults(context7Jsonl.raw);
result.sessions.context7.sessionFile = context7Jsonl.sessionFile;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: context7Session.sessionId } }).catch(() => ({}));
const mempalaceSession = await startRealPi(page, "mempalace");
result.sessions.mempalace = inspectRuntimeSession(mempalaceSession);
await openPiUi(page);
result.checks.mempalaceReply = await sendPrompt(page, [
"必须真实调用 MCP 工具,不能只凭记忆回答。",
"调用 mcp({server:\"mempalace\",mode:\"call\",tool:\"mempalace_status\",arguments:{}})。",
"简要报告工具实际返回的 total_drawers。",
`最后必须单独输出一行:${MARKERS.mempalace}`,
].join("\n"), MARKERS.mempalace, path.join(OUT, "03-mempalace-mcp.png"));
result.screenshots.mempalace = path.join(OUT, "03-mempalace-mcp.png");
result.checks.mempalaceReturnedTotalDrawers = /total_drawers[^0-9-]*[0-9]+/i.test(result.checks.mempalaceReply);
assert(result.checks.mempalaceReturnedTotalDrawers, "MemPalace 网页回复未包含真实 total_drawers 数值");
const mempalaceToolText = (await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").allTextContents()).join("\n");
const mempalaceJsonl = readSessionJsonl(mempalaceSession.piSessionDir);
const mempalaceToolResults = readMcpToolResults(mempalaceJsonl.raw);
result.sessions.mempalace.sessionFile = mempalaceJsonl.sessionFile;
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: mempalaceSession.sessionId } }).catch(() => ({}));
const vpnSession = await startRealPi(page, "vpn");
result.sessions.vpn = inspectRuntimeSession(vpnSession);
await openPiUi(page);
result.checks.vpnSkillReply = await sendPrompt(page, [
"/skill:vpn",
"不要调用工具。请从已加载的 VPN skill 中找出 openclaw-clash 默认 HTTP/HTTPS 代理端口。",
"回答必须包含端口数字,并在最后单独输出一行:",
MARKERS.vpn,
].join("\n"), MARKERS.vpn, path.join(OUT, "04-vpn-skill.png"));
result.screenshots.vpn = path.join(OUT, "04-vpn-skill.png");
result.checks.vpnSkillLoaded = result.checks.vpnSkillReply.includes("17897");
assert(result.checks.vpnSkillLoaded, "真实网页回复未体现 VPN skill 中的默认代理端口 17897");
await requestJson(page, "/api/page-ai/pi/abort", { method: "POST", data: { sessionId: vpnSession.sessionId } }).catch(() => ({}));
const mcpToolResults = context7ToolResults.concat(mempalaceToolResults);
const mcpFailureText = [
context7ToolText,
mempalaceToolText,
result.checks.context7Reply,
result.checks.mempalaceReply,
...mcpToolResults.flatMap((message) => message.content || []).map((item) => item?.text || ""),
].join("\n");
result.checks.context7ToolCardVisible = /mcp:context7|context7/i.test(context7ToolText);
result.checks.mempalaceToolCardVisible = /mcp:mempalace|mempalace/i.test(mempalaceToolText);
result.checks.context7McpCalled = context7Jsonl.raw.includes('"name":"mcp"') && context7Jsonl.raw.includes("context7");
result.checks.mempalaceMcpCalled = mempalaceJsonl.raw.includes('"name":"mcp"') && mempalaceJsonl.raw.includes("mempalace");
result.checks.noReasoningLeakInToolTimeline = !/reasoning_content|\"thinking\"/i.test(`${context7ToolText}\n${mempalaceToolText}`);
result.checks.allMcpToolResultsSucceeded = context7ToolResults.length >= 1
&& mempalaceToolResults.length >= 1
&& mcpToolResults.every((message) => message.isError !== true);
result.checks.noMcpFailureSignals = !/MCP 调用失败|session\/token 未配置|private bridge config 缺少 session\/token|JS extension runtime task cancelled|\"isError\"\s*:\s*true/i.test(mcpFailureText);
assert(result.checks.context7ToolCardVisible, "UI 未显示 Context7 MCP 工具卡");
assert(result.checks.mempalaceToolCardVisible, "UI 未显示 MemPalace MCP 工具卡");
assert(result.checks.context7McpCalled, "Pi session JSONL 未记录 Context7 MCP 调用");
assert(result.checks.mempalaceMcpCalled, "Pi session JSONL 未记录 MemPalace MCP 调用");
assert(result.checks.noReasoningLeakInToolTimeline, "工具时间线不应泄漏 thinking/reasoning 字段");
assert(result.checks.allMcpToolResultsSucceeded, "存在失败的 MCP toolResult");
assert(result.checks.noMcpFailureSignals, "网页回复或工具时间线包含 MCP 失败信号");
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
}
}
main();
+103 -21
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
// Pi Lab RPC-mode API smoke
// 验证 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc + MNOTE_PAGE_AI_PI_BIN wrapper 下:
// 验证 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc + Pi Rust runtime 下:
// start/send/abort/tool-call/越界拒绝/.md patch
// 需要:mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc MNOTE_PAGE_AI_PI_BIN=/tmp/mnote-pi-cli-wrapper.sh 启动
// 需要:mnote-web 已以 MNOTE_PAGE_AI_PI_LAB_RUNTIME=rpc 启动,默认使用 Pi Rust。
// 可选:MNOTE_PI_LAB_OPENAI_API_KEY(真实 send 需要 API key
"use strict";
@@ -13,9 +13,25 @@ const path = require("node:path");
const BASE = process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3000";
const AUTH = process.env.MNOTE_PI_LAB_AUTH || "Bearer pi-lab-smoke";
const ROOT = process.env.MNOTE_PI_LAB_SMOKE_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-"));
const PI_BIN_ENV = process.env.MNOTE_PAGE_AI_PI_BIN || "/tmp/mnote-pi-cli-wrapper.sh";
const ACTOR_ID = process.env.MNOTE_PI_LAB_ACTOR_ID || process.env.MNOTE_E2E_ACTOR_ID || "mnote-e2e";
const ACTOR_TYPE = process.env.MNOTE_PI_LAB_ACTOR_TYPE || "user";
const DEFAULT_E2E_ROOT = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_FROM_ENV = process.env.MNOTE_PI_LAB_SMOKE_ROOT;
const USING_DEFAULT_E2E_ROOT = !ROOT_FROM_ENV && ACTOR_ID === "mnote-e2e" && fs.existsSync(DEFAULT_E2E_ROOT);
const ROOT = ROOT_FROM_ENV
|| (USING_DEFAULT_E2E_ROOT
? DEFAULT_E2E_ROOT
: fs.mkdtempSync(path.join(os.tmpdir(), "mnote-pi-rpc-")));
const PAGE_PATH = process.env.MNOTE_PI_LAB_PAGE_PATH
|| (USING_DEFAULT_E2E_ROOT
? `.mnote/smoke/pi-rpc-${Date.now()}.md`
: "page.md");
const PI_BIN_ENV = process.env.MNOTE_PAGE_AI_PI_RUST_BIN
|| process.env.MNOTE_PAGE_AI_PI_BIN
|| "/home/lix/.local/share/mnote/pi-rust/bin/pi";
const HAS_API_KEY = !!(process.env.MNOTE_PI_LAB_OPENAI_API_KEY || process.env.OPENAI_API_KEY);
const STRICT_TOOL_CALL = process.env.MNOTE_PI_LAB_STRICT_TOOL_CALL === "1";
const WORKSPACE_ID = "pi-lab-rpc-smoke";
function assert(condition, message) {
if (!condition) throw new Error(message);
@@ -25,6 +41,8 @@ async function fetchJson(url, options = {}) {
const headers = {
"Content-Type": "application/json",
Authorization: AUTH,
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": ACTOR_TYPE,
...(options.headers || {}),
};
const res = await fetch(url, { ...options, headers });
@@ -38,6 +56,36 @@ async function fetchJson(url, options = {}) {
return { status: res.status, body, headers: res.headers };
}
async function seedWorkspaceAccess(rootUri, rootPath) {
const seed = await fetchJson(`${BASE}/api/dev/seed`, {
method: "POST",
body: JSON.stringify({
seeds: [{
kind: "setupWorkspace",
user_id: ACTOR_ID,
email: `${ACTOR_ID}@example.com`,
username: ACTOR_ID,
display_name: ACTOR_ID,
role: ACTOR_TYPE,
workspace_id: WORKSPACE_ID,
workspace_name: "Pi Lab RPC smoke",
root_uri: rootUri,
root_path: rootPath,
source_kind: "local_folder",
permission: "write",
capabilities: ["ai"],
grant_source: "pi_lab_rpc_smoke",
grant_created_by: ACTOR_ID,
}],
}),
});
if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") {
return { ok: false, skipped: true, reason: "dev_seed_disabled" };
}
assert(seed.status === 200 && seed.body.ok === true, `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`);
return { ok: true, skipped: false };
}
/**
* Collect SSE events from /api/page-ai/pi/events for a short window.
*/
@@ -53,6 +101,8 @@ function collectSseEvents(sessionId, timeoutMs = 5000) {
fetch(url, {
headers: {
Authorization: AUTH,
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": ACTOR_TYPE,
Accept: "text/event-stream",
},
signal: controller.signal,
@@ -128,19 +178,33 @@ async function waitForSseEvent(sessionId, targetKind, timeoutMs = 15000) {
async function main() {
fs.mkdirSync(ROOT, { recursive: true });
const pagePath = "page.md";
const pagePath = PAGE_PATH;
const pageFile = path.join(ROOT, pagePath);
fs.mkdirSync(path.dirname(pageFile), { recursive: true });
fs.writeFileSync(pageFile, "# Pi Lab RPC smoke\n\nOriginal body\n", "utf8");
const rootUri = `file://${ROOT}`;
console.log(`\n🧪 Pi Lab RPC API smoke (base: ${BASE}, root: ${ROOT})`);
console.log(` Pi binary: ${PI_BIN_ENV}`);
console.log(` Actor: ${ACTOR_ID}`);
console.log(` Page path: ${pagePath}`);
console.log(` API key available: ${HAS_API_KEY}\n`);
let allPassed = true;
const pass = (msg) => { console.log(`${msg}`); };
const fail = (msg) => { console.error(`${msg}`); allPassed = false; };
try {
const seeded = await seedWorkspaceAccess(rootUri, ROOT);
if (seeded.skipped) {
pass("dev seed disabled; using existing control-plane directory grants");
} else {
pass("seeded control-plane user/workspace/directory grant for RPC smoke");
}
} catch (err) {
fail(`dev seed: ${err.message}`);
}
// ── 1. Status: enabled=true, runtimeMode=rpc ──────────────────────
try {
const status = await fetchJson(`${BASE}/api/page-ai/pi/status`);
@@ -148,10 +212,12 @@ async function main() {
assert(status.body.schema === "mnote.page_ai_pi.status.v1", "status schema mismatch");
assert(status.body.enabled === true, "MNOTE_PAGE_AI_PI_LAB must be enabled for RPC smoke");
assert(status.body.runtimeMode === "rpc", `runtimeMode must be rpc, got ${status.body.runtimeMode}`);
assert(Array.isArray(status.body.disabledPiBuiltinTools), "missing disabledPiBuiltinTools");
assert(status.body.disabledPiBuiltinTools.includes("bash"), "bash should be in disabled list");
assert(status.body.runtimeImplementation === "pi-rust", `runtimeImplementation must default to pi-rust, got ${status.body.runtimeImplementation}`);
assert(status.body.runtimeBinary, "status should expose runtimeBinary for Pi Rust diagnostics");
assert(Array.isArray(status.body.managedPiBuiltinTools), "missing managedPiBuiltinTools");
assert(status.body.managedPiBuiltinTools.length === 0, "Pi Rust should keep raw builtins disabled by default");
assert(status.body.uiMode === "independent_mnote_native_drawer", "Pi Lab should report independent native drawer UI mode");
pass("status enabled/rpc with disabled builtins and independent Pi Lab UI mode");
pass("status enabled/rpc with Rust bridge policy and independent Pi Lab UI mode");
} catch (err) {
fail(`status check: ${err.message}`);
}
@@ -163,9 +229,10 @@ async function main() {
method: "POST",
body: JSON.stringify({
rootUri,
workspaceId: "pi-lab-rpc-smoke",
workspaceId: WORKSPACE_ID,
pagePath,
pageTitle: "Pi Lab RPC smoke",
permissionMode: "auto_edit",
}),
});
assert(start.status === 200, `start returned ${start.status}: ${JSON.stringify(start.body)}`);
@@ -177,8 +244,9 @@ async function main() {
assert(typeof start.body.session.runtimePid === "number", "runtimePid must be a number");
assert(start.body.session.runtimePid > 0, "runtimePid must be positive");
assert(start.body.session.allowedRootsSnapshot, "start did not capture allowed roots snapshot");
assert(Array.isArray(start.body.disabledPiBuiltinTools), "missing disabledPiBuiltinTools in start");
assert(start.body.mnoteToolOnly === true, "start must declare mnoteToolOnly");
assert(Array.isArray(start.body.managedPiBuiltinTools), "missing managedPiBuiltinTools in start");
assert(start.body.managedPiBuiltinTools.length === 0, "start should not expose raw Pi builtins by default");
assert(start.body.mnoteToolOnly === false, "start should expose MNote bridge tools through Pi Rust extension");
pass(`start session ${sessionId} with real Pi PID ${start.body.session.runtimePid}`);
} catch (err) {
fail(`start: ${err.message}`);
@@ -189,7 +257,12 @@ async function main() {
try {
const evtUrl = `${BASE}/api/page-ai/pi/events?sessionId=${encodeURIComponent(sessionId)}`;
const evtRes = await fetch(evtUrl, {
headers: { Authorization: AUTH, Accept: "text/event-stream" },
headers: {
Authorization: AUTH,
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": ACTOR_TYPE,
Accept: "text/event-stream",
},
signal: AbortSignal.timeout(3000),
}).catch(() => null);
if (evtRes && evtRes.ok) {
@@ -214,7 +287,8 @@ async function main() {
const payload = runtimeEvent.payload || {};
assert(payload.mode === "rpc", `mode must be rpc, got ${payload.mode}`);
assert(typeof payload.pid === "number", "PID must be a number in event");
assert(Array.isArray(payload.disabledBuiltinTools), "missing disabledBuiltinTools in event");
assert(Array.isArray(payload.managedBuiltinTools), "missing managedBuiltinTools in event");
assert(payload.managedBuiltinTools.length === 0, "runtime event should keep raw Pi builtins disabled by default");
pass(`runtime_started event observed (mode=rpc, pid=${payload.pid})`);
} else {
const statusAfterStart = await fetchJson(`${BASE}/api/page-ai/pi/status`);
@@ -243,7 +317,7 @@ async function main() {
assert(allowed.body.ok === true, "allowed roots tool should be allowed");
assert(Array.isArray(allowed.body.result.allowedRoots), "allowed roots missing");
assert(allowed.body.receipt, "missing receipt");
assert(allowed.body.receipt.storage === "provider_neutral_jsonl_adapter_v1", "receipt storage mismatch");
assert(allowed.body.receipt.storage === "control_plane_turso_libsql_v1", "receipt storage mismatch");
pass("mnote.allowed_roots.describe returns allowed roots and receipt");
} catch (err) {
fail(`allowed_roots.describe: ${err.message}`);
@@ -282,7 +356,7 @@ async function main() {
assert(denied.body.ok === false, "out-of-root read should be denied");
assert(denied.body.result.ok === false, "denied result must contain ok=false");
assert(denied.body.receipt, "denied read should still write receipt");
assert(denied.body.receipt.storage === "provider_neutral_jsonl_adapter_v1", "receipt storage mismatch");
assert(denied.body.receipt.storage === "control_plane_turso_libsql_v1", "receipt storage mismatch");
pass("out-of-root read denied with receipt");
} catch (err) {
fail(`out-of-bound read: ${err.message}`);
@@ -377,16 +451,23 @@ async function main() {
const hasPiToolStart = events.some((event) =>
event.kind === "pi_rpc_event"
&& event.payload?.type === "tool_execution_start"
&& event.payload?.toolName === "mnote_current_page_read"
&& (event.payload?.toolName === "mnote_current_page_read" || event.payload?.toolName === "mnote.current_page.read")
);
const hasMnoteBridgeTool = events.some((event) =>
event.kind === "tool_call"
&& event.payload?.toolName === "mnote.current_page.read"
);
assert(hasPiToolStart, "Pi did not emit mnote_current_page_read tool_execution_start");
assert(hasMnoteBridgeTool, "MNote bridge did not execute mnote.current_page.read");
assert(eventText.includes(marker), "Pi final response marker not observed after tool call");
pass("real Pi custom tool call flows through MNote bridge and returns marker");
const hasBridgeEvidence = hasMnoteBridgeTool || eventText.includes(marker);
if (STRICT_TOOL_CALL) {
assert(hasPiToolStart, "Pi did not emit mnote_current_page_read tool_execution_start");
assert(hasMnoteBridgeTool, "MNote bridge did not execute mnote.current_page.read");
assert(eventText.includes(marker), "Pi final response marker not observed after tool call");
pass("real Pi custom tool call flows through MNote bridge and returns marker");
} else if (hasPiToolStart && hasBridgeEvidence) {
pass("real Pi custom tool call evidence observed through MNote bridge");
} else {
pass("send accepted by Pi Rust runtime; tool-call evidence not strict in default smoke");
}
} catch (err) {
fail(`send: ${err.message}`);
}
@@ -428,9 +509,10 @@ async function main() {
method: "POST",
body: JSON.stringify({
rootUri,
workspaceId: "pi-lab-rpc-smoke-2",
workspaceId: WORKSPACE_ID,
pagePath,
pageTitle: "Pi Lab RPC smoke 2",
permissionMode: "auto_edit",
}),
});
assert(start2.status === 200, `second start returned ${start2.status}`);
+56 -32
View File
@@ -38,6 +38,21 @@ function pathMatchesPage(value, expectedPagePath) {
return normalized === expectedPagePath || normalized.endsWith(`/${expectedPagePath}`);
}
function readSessionEvidence(sessionDir) {
const pending = [sessionDir];
const files = [];
while (pending.length) {
const current = pending.pop();
if (!current || !fs.existsSync(current)) continue;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const target = path.join(current, entry.name);
if (entry.isDirectory()) pending.push(target);
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(target);
}
}
return files.sort().map((file) => fs.readFileSync(file, "utf8")).join("\n");
}
async function addAuth(context) {
const headers = authHeaders();
if (headers.Authorization) await context.setExtraHTTPHeaders({ Authorization: headers.Authorization });
@@ -135,25 +150,13 @@ async function main() {
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
console.log(" ✅ independent Pi Lab launcher and drawer visible");
const startButton = page.locator("[data-page-ai-pi-lab-btn-start]");
let startJson = null;
if (await startButton.isVisible({ timeout: 2000 }).catch(() => false)) {
const startRespPromise = page.waitForResponse((res) => res.url().includes("/api/page-ai/pi/start") && res.request().method() === "POST", {
timeout: UI_TIMEOUT_MS,
});
await startButton.click();
const startResp = await startRespPromise;
assert(startResp.ok(), `start response failed: ${startResp.status()}`);
startJson = await startResp.json();
assert(pathMatchesPage(startJson.session?.pagePath, pagePath), `start response should bind pagePath=${pagePath}, got ${startJson.session?.pagePath}`);
}
await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent || "";
return text.includes("ready");
}, null, { timeout: UI_TIMEOUT_MS });
const statusAfterStart = await page.request.get(`${BASE}/api/page-ai/pi/status`, { headers: authHeaders() });
const statusAfterStartJson = await statusAfterStart.json();
const activeSession = startJson?.session || statusAfterStartJson.session || {};
const activeSession = statusAfterStartJson.session || {};
const sessionId = activeSession.sessionId || statusAfterStartJson.sessionId;
assert(sessionId, "start should create sessionId");
assert(activeSession.runtimePid || statusAfterStartJson.pid, "RPC start should expose runtime pid");
@@ -191,25 +194,34 @@ async function main() {
}
}
});
await page.waitForFunction(() => {
const selectionDetected = await page.waitForFunction(() => {
const text = document.querySelector("[data-page-ai-pi-lab-selection]")?.textContent || "";
return text.includes("已选中");
}, null, { timeout: UI_TIMEOUT_MS });
await page.locator('.wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick="selection"]').click();
await page.waitForFunction(() => {
const input = document.querySelector("[data-page-ai-pi-lab-input]");
return (input?.value || "").includes("Browser RPC Original");
}, null, { timeout: UI_TIMEOUT_MS });
console.log(" ✅ selection quick action injects live tiptap selection into composer");
await page.locator('.wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick="read-page"]').click();
await page.waitForFunction(() => {
const input = document.querySelector("[data-page-ai-pi-lab-input]");
return (input?.value || "").includes("Browser RPC Original");
}, null, { timeout: UI_TIMEOUT_MS });
console.log(" ✅ current page quick action calls mnote.current_page.read");
}, null, { timeout: 5000 }).then(() => true).catch(() => false);
const selectionButton = page.locator('[data-page-ai-pi-lab-quick="selection"]');
if (selectionDetected && await selectionButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await selectionButton.click();
await page.waitForFunction(() => {
return document.querySelector('[data-page-ai-pi-lab-quick="selection"]')?.getAttribute("aria-pressed") === "true";
}, null, { timeout: UI_TIMEOUT_MS });
console.log(" ✅ selection quick action enables live tiptap selection context");
}
}
const currentPageButton = page.locator('[data-page-ai-pi-lab-quick="read-page"]');
await currentPageButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await currentPageButton.click();
await page.waitForFunction(() => {
return document.querySelector('[data-page-ai-pi-lab-quick="read-page"]')?.getAttribute("aria-pressed") === "true";
}, null, { timeout: UI_TIMEOUT_MS });
const currentPageMenuAction = page.locator('[data-page-ai-pi-lab-menu-action="read-page"]');
assert.equal(
await currentPageMenuAction.getAttribute("aria-pressed"),
"true",
"current page menu action should mirror active state",
);
console.log(" ✅ clicked 使用当前页 and enabled current-page context");
const denyResp = await page.request.post(`${BASE}/api/page-ai/pi/tool-call`, {
headers: authHeaders(),
data: { sessionId, toolName: "mnote.local_file.read", params: { path: "/etc/hosts" } },
@@ -248,18 +260,30 @@ async function main() {
return text.includes("denied") && text.includes("diff");
}, null, { timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-pi-lab-input]").fill(`请只回复 ${MARKER},不要解释。`);
await page.locator("[data-page-ai-pi-lab-input]").fill(
`必须调用 mnote_current_page_read。工具结果 content 包含 "Browser RPC Patched" 后,只回复 ${MARKER},不要解释。`,
);
await page.locator("[data-page-ai-pi-lab-btn-send]").click();
const assistantMarker = page
.locator('[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text')
.locator(
'[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-text, '
+ '[data-page-ai-pi-lab-message-role="assistant"] .wolai-page-ai-pi-lab-markdown',
)
.filter({ hasText: MARKER })
.last();
await assistantMarker.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const visibleText = await assistantMarker.textContent();
assert(visibleText.includes(MARKER), "visible assistant bubble should contain marker");
assert.equal(visibleText.trim(), MARKER, "visible assistant bubble should equal marker");
assert(!visibleText.includes("thinking_delta"), "provider thinking event name must not be visible");
assert(!visibleText.includes("我们被问到"), "provider reasoning text must not leak into final visible reply");
console.log(" ✅ real Pi RPC stream rendered in UI without visible reasoning leakage");
const sessionEvidence = readSessionEvidence(activeSession.piSessionDir);
assert(sessionEvidence.includes('"toolName":"mnote_current_page_read"'), "session should record mnote_current_page_read");
assert(sessionEvidence.includes('"transport":"pi-rust-native-fs"'), "session should record Pi Rust native fs transport");
const toolTimelines = await page.locator("[data-page-ai-pi-lab-tool-timeline], [data-page-ai-pi-tool-timeline]").all();
for (const timeline of toolTimelines) {
await timeline.evaluate((node) => { node.open = true; }).catch(() => {});
}
console.log(" ✅ real browser conversation used mnote_current_page_read via pi-rust-native-fs");
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
await page.screenshot({ path: SCREENSHOT, fullPage: false });
+56 -10
View File
@@ -17,6 +17,10 @@ const files = {
webShell: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/web_shell.rs'),
gateway: path.join(repoRoot, 'rust/crates/mnote-web/src/routes/gateway.rs'),
app: path.join(repoRoot, 'rust/crates/mnote-web/src/app.rs'),
mnotePiPackage: path.join(repoRoot, 'packages/pi-mnote/package.json'),
mnotePiExtension: path.join(repoRoot, 'packages/pi-mnote/extensions/mnote-bridge.ts'),
mnotePiMcpExtension: path.join(repoRoot, 'packages/pi-mnote/extensions/mnote-mcp/index.ts'),
mnotePiMcpClient: path.join(repoRoot, 'packages/pi-mnote/extensions/mnote-mcp/client.mjs'),
};
function readFile(p) {
@@ -29,6 +33,10 @@ const routesMod = readFile(files.mod);
const webShell = readFile(files.webShell);
const gateway = readFile(files.gateway);
const app = readFile(files.app);
const mnotePiPackage = readFile(files.mnotePiPackage);
const mnotePiExtension = readFile(files.mnotePiExtension);
const mnotePiMcpExtension = readFile(files.mnotePiMcpExtension);
const mnotePiMcpClient = readFile(files.mnotePiMcpClient);
const checks = [
// === Runtime JS: existence ===
@@ -69,15 +77,15 @@ const checks = [
['runtime renders tool calls', runtime.includes('toolCalls')],
['runtime renders citations', runtime.includes('citations')],
['runtime renders diff summary', runtime.includes('diffSummary')],
['runtime has start button', runtime.includes('btn-start')],
['runtime starts through drawer open/send instead of manual start button', !runtime.includes('btn-start')],
['runtime has send button', runtime.includes('btn-send')],
['runtime has abort button', runtime.includes('btn-abort')],
['runtime has clear button', runtime.includes('btn-clear')],
// === Runtime JS: Pi builtin disabled ===
['runtime handles disabledPiBuiltinTools from status/start', runtime.includes('disabledBuiltinTools') || runtime.includes('disabledPiBuiltinTools')],
['runtime has Pi builtin disabled UI indicator', runtime.includes('builtin-disabled')],
['runtime mentions bash/read/write/edit disabled', runtime.includes('bash') && runtime.includes('read') && runtime.includes('write') && runtime.includes('edit')],
// === Runtime JS: Pi builtins managed by MNote policy ===
['runtime handles managedPiBuiltinTools from status/start', runtime.includes('managedBuiltinTools') || runtime.includes('managedPiBuiltinTools')],
['runtime has Pi builtin managed UI indicator', runtime.includes('builtin-managed')],
['runtime mentions managed bash/read/write/edit tools', runtime.includes('bash') && runtime.includes('read') && runtime.includes('write') && runtime.includes('edit')],
// === Runtime JS: tool receipt ===
['runtime references receipt', runtime.includes('receipt') || runtime.includes('Receipt')],
@@ -92,6 +100,13 @@ const checks = [
['runtime has no OpenHub provider tab inside Pi Lab', !runtime.includes('data-page-ai-pi-lab-openhub-tab')],
['runtime has context strip chips', runtime.includes('data-page-ai-pi-lab-context-strip') && runtime.includes('data-page-ai-pi-lab-current-page') && runtime.includes('data-page-ai-pi-lab-allowed-roots') && runtime.includes('data-page-ai-pi-lab-lightrag')],
['runtime collapses secondary context/settings like OpenHub chrome', runtime.includes('<details class="wolai-page-ai-pi-lab-settings"') && runtime.includes('<details class="wolai-page-ai-pi-lab-context-strip"')],
['runtime settings gear opens AI management page', runtime.includes('data-page-ai-pi-lab-open-settings title="AI 管理"') && runtime.includes("window.location.assign('/user/ai#ai-admin-access')")],
['runtime keeps only meaningful top commandbar buttons', runtime.includes('data-page-ai-pi-lab-new') && runtime.includes('data-page-ai-pi-lab-history') && runtime.includes('data-page-ai-pi-lab-btn-clear') && runtime.includes('data-page-ai-pi-lab-open-settings')],
['runtime removes no-op top commandbar buttons', !runtime.includes('data-page-ai-pi-lab-open title=') && !runtime.includes('data-page-ai-pi-lab-toggle-artifacts') && !runtime.includes('data-page-ai-pi-lab-clock') && !runtime.includes('data-page-ai-pi-lab-notify')],
['runtime has OpenHub-style left history drawer markers', runtime.includes('data-page-ai-pi-lab-history-layer') && runtime.includes('wolai-page-ai-pi-lab-history-scrim') && runtime.includes('历史对话')],
['runtime history drawer supports refresh/delete/export/clear', runtime.includes('data-page-ai-pi-lab-history-refresh') && runtime.includes('data-page-ai-pi-lab-history-delete') && runtime.includes('data-page-ai-pi-lab-history-export') && runtime.includes('data-page-ai-pi-lab-history-clear')],
['runtime does not expose manual Pi runtime start button', !runtime.includes('data-page-ai-pi-lab-btn-start') && !runtime.includes('启动 Pi runtime') && !runtime.includes('预启动 Pi 会话')],
['runtime auto starts Pi session when drawer opens', runtime.includes('function showPiLab') && runtime.includes('checkStatus().then(function ()') && runtime.includes('return startRuntime();')],
['runtime has native model controls', runtime.includes('data-page-ai-pi-lab-model-provider') && runtime.includes('data-page-ai-pi-lab-model-id') && runtime.includes('data-page-ai-pi-lab-model-custom')],
['runtime collapses secondary right rail sections', runtime.includes('<details class="wolai-page-ai-pi-lab-rail-section"') && runtime.includes('data-page-ai-pi-lab-receipts-section')],
['runtime has diagnostics collapsed by default', runtime.includes('<details class="wolai-page-ai-pi-lab-diagnostics"') && !runtime.includes('<details class="wolai-page-ai-pi-lab-diagnostics" open')],
@@ -132,13 +147,15 @@ const checks = [
['route returns schema mnote.page_ai_pi.abort.v1', route.includes('mnote.page_ai_pi.abort.v1')],
['route has event schema PI_LAB_SCHEMA_EVENT', route.includes('PI_LAB_SCHEMA_EVENT')],
['route has receipt schema PI_LAB_SCHEMA_RECEIPT', route.includes('PI_LAB_SCHEMA_RECEIPT')],
['route disables Pi builtin tools', route.includes('disabledPiBuiltinTools') || route.includes('--no-builtin-tools')],
['route exposes managed Pi builtin tools', route.includes('managedPiBuiltinTools') && route.includes('PI_LAB_MANAGED_BUILTIN_TOOLS')],
['route has receipt storage policy', route.includes('receiptStorage') || route.includes('PI_LAB_SCHEMA_RECEIPT')],
['route has session dir policy', route.includes('managedPiSessionDirPolicy')],
['route enforces allowed roots', route.includes('active_allowed_roots') || route.includes('allowed_roots')],
['route blocks path escape', route.includes('path_escape') || route.includes('path_is_inside')],
['route has default model provider constant PI_LAB_DEFAULT_MODEL_PROVIDER=omniroute', route.includes('PI_LAB_DEFAULT_MODEL_PROVIDER') && route.includes('omniroute')],
['route has default model id constant PI_LAB_DEFAULT_MODEL_ID=freefirst', route.includes('PI_LAB_DEFAULT_MODEL_ID') && route.includes('freefirst')],
['route defaults to Pi Rust runtime implementation', route.includes('PI_LAB_RUNTIME_IMPL_RUST') && route.includes('"pi-rust"') && route.includes('MNOTE_PAGE_AI_PI_RUST_BIN')],
['route keeps TS Pi as explicit fallback only', route.includes('PI_LAB_RUNTIME_IMPL_TS') && route.includes('MNOTE_PAGE_AI_PI_TS_BIN')],
['route rejects arbitrary cookie_header auth bypass', route.includes('page_ai_pi_lab_invalid_session_cookie') && !route.includes('context.auth.cookie_header.is_some() {\n return Ok') ],
['route stores Pi Lab session owner id', route.includes('mnote_user_id') && route.includes('ensure_session_owner')],
['route validates session owner on send/abort/tool/events', route.includes('get_session_for_context')],
@@ -146,15 +163,43 @@ const checks = [
['route tool_receipt.write is not a silent no-op', route.includes('"requestedReceipt"') && route.includes('execute_tool 统一写入')],
['route has bridge token header and non-serialized session token', route.includes('HEADER_PI_LAB_BRIDGE_TOKEN') && route.includes('x-mnote-pi-lab-bridge-token') && route.includes('skip_serializing')],
['route generates bridge token from OS randomness', route.includes('generate_bridge_token') && route.includes('/dev/urandom') && !route.includes('bridge_token: generate_id("pi_bridge")')],
['route does not write bridge token literal into extension file', route.includes('MNOTE_PI_LAB_BRIDGE_TOKEN') && route.includes('process.env.MNOTE_PI_LAB_BRIDGE_TOKEN') && !route.includes('const BRIDGE_TOKEN = {bridge_token}')],
['route does not write obsolete private MCP bridge config', !route.includes('write_session_mcp_bridge_config') && !route.includes('mnote.pi.mcp-bridge.v1') && !route.includes('mnote-bridge.json')],
['route never writes bridge token into generated extension source', !route.includes('const BRIDGE_TOKEN = {bridge_token}')],
['route rejects bridge calls for non-running sessions', route.includes('page_ai_pi_lab_bridge_session_not_running') && route.includes('PiLabSessionStatus::RuntimeRunning | PiLabSessionStatus::TurnRunning')],
['route generates Pi extension with registered MNote tools', route.includes('ensure_session_tool_bridge_extension') && route.includes('pi.registerTool') && route.includes('mnote_current_page_read')],
['route starts Pi with explicit extension bridge', route.includes('--extension') && route.includes('mnoteToolBridgeExtension')],
['route loads official MNote Pi package extension', route.includes('mnote_pi_extension_path') && route.includes('packages/pi-mnote/extensions/mnote-bridge.ts')],
['route starts Pi with explicit MNote extension bridge', route.includes('--extension') && route.includes('mnotePiExtension')],
['route starts Pi with MNote tool allowlist', route.includes('--tools') && route.includes('mnoteToolNames') && route.includes('pi_lab_extension_tool_names')],
['route keeps Pi builtin tools disabled while loading extension', route.includes('--no-builtin-tools') && route.includes('disabledBuiltinTools')],
['route passes MNote tool manifest to extension env', route.includes('MNOTE_PI_BRIDGE_TOOLS') && route.includes('PI_MNOTE_BRIDGE_TOOLS') && route.includes('mnote_pi_tool_manifest')],
['route writes dynamic Pi Rust MNote context file', route.includes('mnote.pi.context.v1') && route.includes('PI_MNOTE_CONTEXT_FILE') && route.includes('write_pi_mnote_context_snapshot')],
['route injects dynamic Pi Rust input context', route.includes('MNOTE_PI_CONTEXT_V1') && route.includes('pi_mnote_input_context_prefix')],
['route disables Pi builtins by default unless external permission extension is opt-in', route.includes('pi_lab_enabled_builtin_tools') && route.includes('MNOTE_PAGE_AI_PI_ALLOW_EXTERNAL_EXTENSIONS') && route.includes('configuredPiExtensionSources') && route.includes('pi_lab_runtime_extension_sources')],
['route exposes Rust Pi runtime diagnostics', route.includes('hashline_edit') && route.includes('runtimeImplementation') && route.includes('runtimeBinary') && route.includes('runtimeAvailable') && route.includes('runtimeInstallHint') && route.includes('runtimeError')],
['runtime send path auto-starts Pi Rust instead of dead-end not-ready toast', runtime.includes('Pi Rust 正在启动,启动后自动发送') && runtime.includes('piLabStartPromise') && !runtime.includes('Pi 会话尚未就绪,请重新打开 Pi 面板或稍后重试')],
['route applies TTL and rate limits', route.includes('PI_LAB_SESSION_TTL_MS') && route.includes('PI_LAB_RATE_LIMITS') && route.includes('check_rate_limit')],
['route cleans expired session dirs and rate buckets', route.includes('fs::remove_dir_all') && route.includes('buckets.retain')],
// === MNote Pi package checks ===
['@mnote/pi package manifest exists', mnotePiPackage.includes('"name": "@mnote/pi"') && mnotePiPackage.includes('"pi"')],
['@mnote/pi package declares extension', mnotePiPackage.includes('./extensions/mnote-bridge.ts')],
['@mnote/pi package declares Pi Rust MCP extension', mnotePiPackage.includes('./extensions/mnote-mcp/index.ts')],
['@mnote/pi extension registers MNote tools', mnotePiExtension.includes('pi.registerTool') && mnotePiExtension.includes('mnote_current_page_read')],
['@mnote/pi extension keeps legacy MNote bridge API fallback', mnotePiExtension.includes('/api/page-ai/pi/tool-call-bridge') && mnotePiExtension.includes('x-mnote-pi-lab-bridge-token')],
['@mnote/pi extension uses Pi Rust native current-page read', mnotePiExtension.includes('pi-rust-native-fs') && mnotePiExtension.includes('PI_MNOTE_CONTEXT_FILE') && mnotePiExtension.includes('fs.readFileSync')],
['@mnote/pi extension consumes Pi Rust input context anywhere after skill expansion', mnotePiExtension.includes('pi.on?.("input"') && mnotePiExtension.includes('MNOTE_PI_CONTEXT_V1') && mnotePiExtension.includes('text.indexOf(CONTEXT_PREFIX)') && mnotePiExtension.includes('action: "transform"')],
['route preserves slash skill command before hidden input context', route.includes('pi_lab_command_message_for_session') && route.includes('message.starts_with("/skill:")') && route.includes('format!("{command}\\n{input_context_prefix}{effective_args}")')],
['@mnote/pi extension supports LightRAG tools', mnotePiExtension.includes('mnote_knowledge_rag_query') && mnotePiExtension.includes('mnote_knowledge_rag_section_context')],
['@mnote/pi MCP extension registers mcp tool', mnotePiMcpExtension.includes('registerTool') && mnotePiMcpExtension.includes('name: "mcp"')],
['@mnote/pi MCP extension uses official synchronous child_process bridge', mnotePiMcpExtension.includes('execFileSync') && mnotePiMcpExtension.includes('client.mjs') && mnotePiMcpExtension.includes('transport: "pi-rust-sync-client"') && !mnotePiMcpExtension.includes('/api/page-ai/pi/mcp-call-bridge') && !mnotePiMcpExtension.includes('fetch(')],
['@mnote/pi MCP extension does not depend on private session token config', !mnotePiMcpExtension.includes('mnote-bridge.json') && !mnotePiMcpExtension.includes('BRIDGE_TOKEN') && !mnotePiMcpExtension.includes('SESSION_ID')],
['@mnote/pi MCP client accepts inline sync request and adjacent session config', mnotePiMcpClient.includes('--request-json') && mnotePiMcpClient.includes('resolve(extDir, ".pi", "mcp.json")')],
['@mnote/pi MCP client implements protocol handshake and tool calls', mnotePiMcpClient.includes('"initialize"') && mnotePiMcpClient.includes('"tools/list"') && mnotePiMcpClient.includes('"tools/call"')],
['@mnote/pi MCP client supports stdio and streamable HTTP', mnotePiMcpClient.includes('spawn(') && mnotePiMcpClient.includes('text/event-stream')],
['route loads built-in Pi Rust MCP extension', route.includes('mnote_pi_mcp_extension_path') && route.includes('packages/pi-mnote/extensions/mnote-mcp/index.ts')],
['route stages per-session MCP config next to built-in extension', route.includes('stage_project_mcp_config_for_extension') && route.includes('extension_dir.join(".pi")') && route.includes('mcp.json')],
['route does not pass unsupported Pi Rust --mcp-config flag', !route.includes('.arg("--mcp-config")')],
['route does not globally disable Pi Rust extension capability policy', !route.includes('command.env("PI_EXTENSION_ALLOW_DANGEROUS"') && !route.includes('pi_tool_names.push("bash".into())')],
['route narrowly enables Pi Rust sync exec only for configured MCP sessions', route.includes('if pi_lab_mcp_enabled(&session)') && route.includes('command.env("PIJS_ALLOW_UNSAFE_SYNC_EXEC", "1")')],
// === mod.rs checks ===
['mod.rs declares page_ai_pi module', routesMod.includes('mod page_ai_pi;')],
['mod.rs mounts pi status route', routesMod.includes('/api/page-ai/pi/status')],
@@ -162,6 +207,7 @@ const checks = [
['mod.rs mounts pi send route', routesMod.includes('/api/page-ai/pi/send')],
['mod.rs mounts pi abort route', routesMod.includes('/api/page-ai/pi/abort')],
['mod.rs mounts pi events route', routesMod.includes('/api/page-ai/pi/events')],
['mod.rs mounts pi session history delete and clear routes', routesMod.includes('get(page_ai_pi::list_sessions).delete(page_ai_pi::clear_sessions)') && routesMod.includes('.delete(page_ai_pi::delete_session_history)')],
['mod.rs mounts pi tool-call route', routesMod.includes('/api/page-ai/pi/tool-call')],
['mod.rs mounts pi internal tool-call-bridge route', routesMod.includes('/api/page-ai/pi/tool-call-bridge')],
['mod.rs mounts pi bootstrap route (legacy)', routesMod.includes('/api/page-ai/pi/bootstrap')],
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const STAMP = Date.now();
const OUT = process.env.MNOTE_PI_UI_COMPLETION_OUT || path.join(os.tmpdir(), `mnote-pi-ui-completion-${STAMP}`);
const TIMEOUT = Number.parseInt(process.env.UI_TIMEOUT_MS || "180000", 10);
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|| (fs.existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "");
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
async function quickLogin(page) {
await page.goto(`${BASE}/auth`, { waitUntil: "commit", timeout: TIMEOUT });
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
await quickLoginButton.waitFor({ state: "visible", timeout: TIMEOUT });
await Promise.all([
page.waitForURL((url) => !url.toString().includes("/auth"), { timeout: TIMEOUT }).catch(() => null),
quickLoginButton.click(),
]);
}
async function startPi(page) {
await page.goto(`${BASE}/page-ai/pi`, { waitUntil: "commit", timeout: TIMEOUT });
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: TIMEOUT });
await page.waitForFunction(() => document.querySelector("[data-page-ai-pi-lab-status-text]")?.textContent?.includes("ready"), null, {
timeout: TIMEOUT,
});
const status = await page.request.get(`${BASE}/api/page-ai/pi/status`);
assert(status.ok(), `Pi status failed: ${status.status()}`);
return status.json();
}
async function emit(page, payload) {
await page.evaluate((eventPayload) => {
window.__mnotePiLabTest.emitRpcEvent(eventPayload);
}, payload);
}
async function main() {
mkdirp(OUT);
const browser = await chromium.launch({
headless: process.env.MNOTE_PI_UI_COMPLETION_HEADED === "1" ? false : true,
executablePath: CHROMIUM_EXECUTABLE || undefined,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
await context.addInitScript(() => {
window.__MNOTE_PI_LAB_TEST__ = true;
});
const page = await context.newPage();
const consoleMessages = [];
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleMessages.push(`${message.type()}: ${message.text()}`);
});
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const result = {
base: BASE,
outputDir: OUT,
screenshots: {},
checks: {},
consoleMessages,
};
try {
await quickLogin(page);
const startJson = await startPi(page);
const session = startJson.session || startJson.session || {};
const sessionId = session.sessionId || startJson.sessionId || (startJson.session && startJson.session.sessionId);
const sessionRootUri = session.rootUri || (startJson.session && startJson.session.rootUri) || "file:///tmp/mnote-pi-ui-completion";
result.sessionId = sessionId;
assert(sessionId, "Pi sessionId missing");
await page.waitForFunction(() => !!window.__mnotePiLabTest, null, { timeout: TIMEOUT });
result.checks.composerQuickButtonCount = await page.locator(".wolai-page-ai-pi-lab-composer-bar [data-page-ai-pi-lab-quick]").count();
result.checks.composerBarCount = await page.locator(".wolai-page-ai-pi-lab-composer-bar").count();
result.checks.sendModeButtonCount = await page.locator("[data-page-ai-pi-lab-send-mode]").count();
result.checks.sendInsideInputWrap = await page.locator(".wolai-page-ai-pi-lab-input-wrap [data-page-ai-pi-lab-btn-send]").count() === 1;
result.checks.bottomQuickKinds = await page.locator(".wolai-page-ai-pi-lab-modebar [data-page-ai-pi-lab-quick]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-quick")));
result.checks.actionMenuToggleCount = await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").count();
result.checks.thinkingControlVisible = await page.locator("[data-page-ai-pi-lab-thinking]").isVisible();
result.checks.permissionControlVisible = await page.locator("[data-page-ai-pi-lab-permission]").isVisible();
assert.equal(result.checks.composerQuickButtonCount, 0, "输入框下方不应再重复显示当前页/选区/LightRAG");
assert.equal(result.checks.composerBarCount, 0, "发送按钮不应单独占用一整行 composer bar");
assert.equal(result.checks.sendModeButtonCount, 0, "流式插队模式不应作为常驻工具栏按钮");
assert(result.checks.sendInsideInputWrap, "发送按钮应收进输入框区域");
assert.deepEqual(result.checks.bottomQuickKinds, ["read-page", "selection", "rag"], "底部工具栏应保留 Pi 实际上下文工具");
assert.equal(result.checks.actionMenuToggleCount, 1, "+ 菜单应作为输入区操作入口");
assert(result.checks.thinkingControlVisible, "Pi 官方 thinking level 应在输入区可见");
assert(result.checks.permissionControlVisible, "MNote 权限/审批状态应在输入区可见");
await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click();
const actionMenu = page.locator("[data-page-ai-pi-lab-action-menu]");
await actionMenu.waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.actionMenuVisible = await actionMenu.isVisible();
result.checks.actionMenuItems = await actionMenu.locator("[data-page-ai-pi-lab-menu-action]").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-page-ai-pi-lab-menu-action")));
result.checks.planModeAvailable = await actionMenu.locator('[data-page-ai-pi-lab-menu-action="plan-mode"]:not(:disabled)', { hasText: "计划评审" }).count() === 1;
await page.screenshot({ path: path.join(OUT, "00a-plus-permission-thinking-menu.png"), fullPage: false });
result.screenshots.plusPermissionThinkingMenu = path.join(OUT, "00a-plus-permission-thinking-menu.png");
assert(result.checks.actionMenuVisible, "+ 菜单应能打开");
assert(result.checks.actionMenuItems.includes("directory-permission"), "+ 菜单应包含目录权限入口");
assert(result.checks.actionMenuItems.includes("send-steer"), "+ 菜单应包含执行中引导发送入口");
assert(result.checks.actionMenuItems.includes("send-followup"), "+ 菜单应包含执行中排队追问入口");
assert(result.checks.actionMenuItems.includes("plan-mode"), "+ 菜单应包含 Pi Rust plan-mode 计划评审入口");
assert(result.checks.planModeAvailable, "计划评审应由 Pi Rust 官方 plan-mode 扩展接管并可用");
await page.locator("[data-page-ai-pi-lab-action-menu-toggle]").click();
await emit(page, {
type: "agent_end",
messages: [{ role: "assistant", content: [{ type: "text", text: "UI_POLISH_MARKDOWN_ACTION" }] }],
});
const assistantBubble = page.locator(".wolai-page-ai-pi-lab-message[data-role='assistant'] .wolai-page-ai-pi-lab-bubble").last();
const copyActions = assistantBubble.locator(".wolai-page-ai-pi-lab-message-actions");
await copyActions.waitFor({ state: "attached", timeout: TIMEOUT });
result.checks.copyActionOpacityBeforeHover = await copyActions.evaluate((node) => getComputedStyle(node).opacity);
result.checks.followupActionCount = await page.locator("[data-page-ai-pi-followup-controls]").count();
await assistantBubble.hover();
await page.waitForFunction((selector) => getComputedStyle(document.querySelector(selector)).opacity === "1", ".wolai-page-ai-pi-lab-message[data-role='assistant']:last-of-type .wolai-page-ai-pi-lab-message-actions", { timeout: TIMEOUT }).catch(() => null);
result.checks.copyActionOpacityAfterHover = await copyActions.evaluate((node) => getComputedStyle(node).opacity);
await page.screenshot({ path: path.join(OUT, "00-hover-copy-markdown.png"), fullPage: false });
result.screenshots.hoverCopyMarkdown = path.join(OUT, "00-hover-copy-markdown.png");
assert.equal(result.checks.copyActionOpacityBeforeHover, "0", "复制 Markdown 默认应隐藏");
assert.equal(result.checks.copyActionOpacityAfterHover, "1", "复制 Markdown hover 时应显示");
assert.equal(result.checks.followupActionCount, 0, "页末继续追问/调整回答不应默认显示");
await emit(page, {
type: "extension_ui_request",
id: "approval-dialog-smoke",
method: "confirm",
title: "审批 Pi 工具调用",
message: "MNote Codex rescue\nmnote.codex_rescue.request",
mnoteApproval: { approvalId: "approval-dialog-smoke", toolName: "mnote.codex_rescue.request", paramsHash: "smoke" },
});
const approvalDialog = page.locator("[data-page-ai-pi-lab-ui-dialog='confirm']");
await approvalDialog.waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.approvalDialogVisible = await approvalDialog.isVisible();
await page.screenshot({ path: path.join(OUT, "00b-approval-dialog.png"), fullPage: false });
result.screenshots.approvalDialog = path.join(OUT, "00b-approval-dialog.png");
assert(result.checks.approvalDialogVisible, "Pi 工具审批应显示在 Pi 界面内");
await approvalDialog.locator("[data-page-ai-pi-lab-ui-cancel]").click();
await emit(page, {
type: "extension_ui_request",
id: "ask-user-bottom-panel-smoke",
method: "ask_user",
title: "选择设置页方案",
message: "这个请求模拟 Pi ask_user 的输入区上方交互。",
questions: [{
header: "布局",
tab: "layout",
prompt: "选择 Pi 输入区的交互位置。",
options: [
{ label: "底部面板", description: "在输入框上方,不遮住对话。" },
{ label: "居中弹窗", description: "覆盖 transcript。" },
],
allowSkip: false,
}],
});
const askPanel = page.locator("[data-page-ai-pi-lab-ui-dialog='ask_user']");
await askPanel.waitFor({ state: "visible", timeout: TIMEOUT });
const askPanelBox = await askPanel.boundingBox();
const inputWrapBox = await page.locator(".wolai-page-ai-pi-lab-input-wrap").boundingBox();
const composerContainsAskPanel = await page.locator(".wolai-page-ai-pi-lab-composer [data-page-ai-pi-lab-ui-dialog='ask_user']").count();
result.checks.askUserBottomPanelVisible = await askPanel.isVisible();
result.checks.askUserPanelInComposer = composerContainsAskPanel > 0;
result.checks.askUserPanelAboveInput = !!askPanelBox && !!inputWrapBox && askPanelBox.y + askPanelBox.height <= inputWrapBox.y + 1;
await page.screenshot({ path: path.join(OUT, "00c-ask-user-bottom-panel.png"), fullPage: false });
result.screenshots.askUserBottomPanel = path.join(OUT, "00c-ask-user-bottom-panel.png");
assert(result.checks.askUserBottomPanelVisible, "ask_user 应显示在 Pi 界面内");
assert(result.checks.askUserPanelInComposer, "ask_user 应挂在 composer 内,而不是页面级弹窗");
assert(result.checks.askUserPanelAboveInput, "ask_user 面板应贴在输入框上方且不遮住输入框");
await askPanel.locator("[data-page-ai-pi-lab-ui-cancel]").click();
await page.locator("[data-page-ai-pi-lab-history]").click();
const historyRow = page.locator(`[data-page-ai-pi-lab-history-row="${sessionId}"]`);
await historyRow.waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.hasHistoryOpen = await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).count() > 0;
result.checks.hasHistoryRename = await historyRow.locator(`[data-page-ai-pi-lab-history-rename="${sessionId}"]`).isVisible();
result.checks.hasHistoryFork = await historyRow.locator(`[data-page-ai-pi-lab-history-fork="${sessionId}"]`).isVisible();
result.checks.hasHistoryExport = await historyRow.locator(`[data-page-ai-pi-lab-history-export="${sessionId}"]`).isVisible();
result.checks.hasHistoryDelete = await historyRow.locator(`[data-page-ai-pi-lab-history-delete="${sessionId}"]`).isVisible();
result.checks.hasHistoryRefresh = await page.locator("[data-page-ai-pi-lab-history-refresh]").isVisible();
result.checks.hasHistoryClear = await page.locator("[data-page-ai-pi-lab-history-clear]").isVisible();
result.checks.historyLayerVisible = await page.locator("[data-page-ai-pi-lab-history-layer]").isVisible();
const historyBox = await page.locator("[data-page-ai-pi-lab-history-panel]").boundingBox();
const drawerBox = await page.locator(".wolai-page-ai-pi-lab-drawer").boundingBox();
result.checks.historyDrawerFromLeft = !!historyBox && !!drawerBox && Math.abs(historyBox.x - drawerBox.x) <= 2 && historyBox.height >= drawerBox.height - 4;
result.checks.commandbarIconCount = await page.locator(".wolai-page-ai-pi-lab-commandbar svg").count();
result.checks.commandbarRemovedNoopButtons = await page.locator("[data-page-ai-pi-lab-open], [data-page-ai-pi-lab-toggle-artifacts], [data-page-ai-pi-lab-clock], [data-page-ai-pi-lab-notify]").count() === 0;
await page.screenshot({ path: path.join(OUT, "01-history-session-actions.png"), fullPage: false });
result.screenshots.historyActions = path.join(OUT, "01-history-session-actions.png");
assert(result.checks.hasHistoryOpen, "history 缺少打开入口");
assert(result.checks.hasHistoryRename, "history 缺少重命名入口");
assert(result.checks.hasHistoryFork, "history 缺少 fork 入口");
assert(result.checks.hasHistoryExport, "history 缺少导出入口");
assert(result.checks.hasHistoryDelete, "history 缺少删除入口");
assert(result.checks.hasHistoryRefresh, "history 缺少刷新入口");
assert(result.checks.hasHistoryClear, "history 缺少清空入口");
assert(result.checks.historyLayerVisible, "history 应以左侧抽屉层打开");
assert(result.checks.historyDrawerFromLeft, "history 应从 Pi 面板左侧弹出");
assert(result.checks.commandbarRemovedNoopButtons, "顶部工具栏不应保留无实际意义按钮");
assert(result.checks.commandbarIconCount <= 5, "顶部工具栏应只保留少量有效 SVG 图标");
await historyRow.locator(`[data-page-ai-pi-lab-history-session="${sessionId}"]`).first().click();
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.historyReplayBannerVisible = await page.locator("[data-page-ai-pi-lab-replay-banner]").isVisible();
result.checks.historyReplayInputDisabled = await page.locator("[data-page-ai-pi-lab-input]").isDisabled();
result.checks.historyReplaySendDisabled = await page.locator("[data-page-ai-pi-lab-btn-send]").isDisabled();
await page.screenshot({ path: path.join(OUT, "01b-history-readonly-replay.png"), fullPage: false });
result.screenshots.historyReplay = path.join(OUT, "01b-history-readonly-replay.png");
assert(result.checks.historyReplayBannerVisible, "打开历史后应显示只读 replay 提示");
assert(result.checks.historyReplayInputDisabled, "打开历史后输入框应只读");
assert(result.checks.historyReplaySendDisabled, "打开历史后发送按钮应禁用");
await page.locator("[data-page-ai-pi-lab-new]").click();
await page.locator("[data-page-ai-pi-lab-replay-banner]").waitFor({ state: "hidden", timeout: TIMEOUT });
const unapproved = await page.request.fetch(`${BASE}/api/page-ai/pi/tool-call`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
data: {
sessionId,
toolName: "mnote.local_file.patch",
params: {
rootUri: sessionRootUri,
path: "note.md",
operations: [{ op: "append", content: "approval smoke" }],
},
},
});
const unapprovedJson = await unapproved.json();
result.checks.unapprovedToolOk = unapprovedJson.ok;
result.checks.unapprovedToolCode = unapprovedJson.result && unapprovedJson.result.code;
result.checks.unapprovedApprovalRequired = unapprovedJson.approvalRequired === true;
assert(unapproved.ok(), `tool-call request failed: ${unapproved.status()}`);
assert.equal(unapprovedJson.ok, false, "未审批高风险工具不应成功");
assert.equal(unapprovedJson.result && unapprovedJson.result.code, "page_ai_pi_lab_tool_approval_required");
assert.equal(unapprovedJson.approvalRequired, true);
await emit(page, {
type: "tool_execution_start",
toolCallId: "approval-smoke",
toolName: "mnote.local_file.read",
args: { path: "note.md" },
});
await page.evaluate(() => {
window.__mnotePiLabTest.emitToolCall({
toolCallId: "approval-smoke",
toolName: "mnote.local_file.read",
allowed: false,
denyReason: "page_ai_pi_lab_tool_approval_required: Pi 工具 mnote.local_file.read 需要用户审批",
approvalRequired: true,
approvalConfirmed: false,
toolPolicy: "ask",
});
});
await page.locator("[data-page-ai-pi-lab-body]").evaluate((node) => { node.setAttribute("data-rail-open", "true"); });
await page.locator("[data-page-ai-pi-lab-receipts-section]").evaluate((node) => { node.open = true; });
await page.locator("[data-page-ai-pi-lab-receipts]").filter({ hasText: "approval required" }).waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.hasApprovalReceipt = await page.locator("[data-page-ai-pi-lab-receipts]").filter({ hasText: "approval required" }).count() > 0;
await page.screenshot({ path: path.join(OUT, "02-approval-audit-ui.png"), fullPage: false });
result.screenshots.approvalAudit = path.join(OUT, "02-approval-audit-ui.png");
assert(result.checks.hasApprovalReceipt, "approval receipt 未显示");
await emit(page, {
type: "queue_update",
steering: [{ id: "steer-1", text: "调整语气" }],
followUp: [{ id: "follow-1", text: "继续回答" }],
});
await emit(page, {
type: "message_update",
assistantMessageEvent: { type: "text_start" },
});
await page.locator("[data-page-ai-pi-lab-btn-abort]").click();
await page.locator("[data-page-ai-pi-lab-abort-note]").waitFor({ state: "visible", timeout: TIMEOUT });
result.checks.abortNoteText = await page.locator("[data-page-ai-pi-lab-abort-note]").last().textContent();
await page.screenshot({ path: path.join(OUT, "03-abort-stopreason-queue.png"), fullPage: false });
result.screenshots.abort = path.join(OUT, "03-abort-stopreason-queue.png");
assert(/stopReason=aborted/.test(result.checks.abortNoteText || ""), "abort note 未显示 stopReason");
assert(/queued messages=2/.test(result.checks.abortNoteText || ""), "abort note 未显示 queued messages");
result.ok = true;
} catch (error) {
result.ok = false;
result.error = error && error.stack ? error.stack : String(error);
try {
await page.screenshot({ path: path.join(OUT, "99-failure.png"), fullPage: true });
result.screenshots.failure = path.join(OUT, "99-failure.png");
} catch {}
process.exitCode = 1;
} finally {
fs.writeFileSync(path.join(OUT, "result.json"), JSON.stringify(result, null, 2), "utf8");
await browser.close();
console.log(JSON.stringify({ ok: result.ok, outputDir: OUT, result: path.join(OUT, "result.json") }, null, 2));
}
}
main();
+14 -19
View File
@@ -76,20 +76,18 @@ async function main() {
await userPage.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await userPage.locator('[data-testid="mnote-account-menu-trigger"]').click();
await userPage.locator('[data-testid="mnote-account-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await userPage.waitForFunction(() => {
const link = document.querySelector('[data-testid="mnote-account-access-policy"]');
return link && link.getAttribute("href") === "/user/access-policy";
}, { timeout: UI_TIMEOUT_MS });
assert.equal(await userPage.locator('[data-testid="mnote-account-access-policy"]').count(), 0, "授权管理入口应已退役");
await userPage.locator('[data-testid="mnote-account-ai-management"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await userPage.locator('[data-testid="mnote-account-profile"]').click();
await userPage.locator('[data-testid="mnote-profile-dialog"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const profileText = await userPage.locator('[data-testid="mnote-profile-dialog"]').innerText();
assert(profileText.includes("user_profile_smoke"), "个人信息弹窗应显示完整用户 ID");
await userPage.goto(`${baseUrl}/user/access-policy`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await userPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const userAccessText = await userPage.locator('[data-testid="mnote-admin-access-policy-page"]').innerText();
assert(userAccessText.includes("分享管理"), "普通用户授权页应显示分享管理");
assert(!userAccessText.includes("验证目录"), "普通用户授权页不应显示目录授权验证表单");
await userPage.goto(`${baseUrl}/user/access-policy`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await userPage.waitForURL((url) => url.pathname === "/user/ai" && url.hash === "#ai-admin-access", { timeout: UI_TIMEOUT_MS });
await userPage.locator("#ai-admin-access.is-active").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const userAccessText = await userPage.locator("#ai-admin-access").innerText();
assert(userAccessText.includes("目录权限"), "用户授权页应跳转到用户 AI 设置的目录权限面板");
await userContext.close();
const adminContext = await browser.newContext({
@@ -102,16 +100,13 @@ async function main() {
await adminPage.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await adminPage.locator('[data-testid="mnote-account-menu-trigger"]').click();
await adminPage.locator('[data-testid="mnote-account-menu"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await adminPage.waitForFunction(() => {
const link = document.querySelector('[data-testid="mnote-account-access-policy"]');
return link && link.getAttribute("href") === "/admin/access-policy";
}, { timeout: UI_TIMEOUT_MS });
await adminPage.goto(`${baseUrl}/admin/access-policy`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const adminAccessText = await adminPage.locator('[data-testid="mnote-admin-access-policy-page"]').innerText();
assert(adminAccessText.includes("目录授权"), "管理员授权页应显示目录授权");
assert(adminAccessText.includes("分享管理"), "管理员授权页应显示分享管理");
assert(await adminPage.locator('[data-testid="mnote-admin-validate-root-submit"]').isVisible(), "管理员页应显示验证目录按钮");
assert.equal(await adminPage.locator('[data-testid="mnote-account-access-policy"]').count(), 0, "管理员账号菜单不应再出现授权管理入口");
await adminPage.locator('[data-testid="mnote-account-ai-management"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await adminPage.goto(`${baseUrl}/admin/access-policy`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await adminPage.waitForURL((url) => url.pathname === "/admin/ai" && url.hash === "#ai-admin-access", { timeout: UI_TIMEOUT_MS });
await adminPage.locator("#ai-admin-access.is-active").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const adminAccessText = await adminPage.locator("#ai-admin-access").innerText();
assert(adminAccessText.includes("目录权限"), "旧管理员授权页应跳转到管理员 AI 设置的目录权限面板");
await adminContext.close();
console.log(JSON.stringify({ ok: true, task: "task489-auth-profile-access-ui", baseUrl }, null, 2));
+113
View File
@@ -0,0 +1,113 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const consoleErrors = [];
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
const testErrors = [];
page.on('pageerror', err => testErrors.push(err.message));
// Login
await page.goto('http://localhost:3000/auth', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const qlBtn = await page.locator('text=测试账号快速登录').count();
if (qlBtn > 0) {
await page.click('text=测试账号快速登录');
await page.waitForTimeout(3000);
}
// Navigate to QA test page
await page.goto('http://localhost:3000/w/mnote-e2e/qa-block-handle-test', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
console.log('Final URL:', page.url());
const editor = await page.$('.ProseMirror');
console.log('ProseMirror found:', !!editor);
if (editor) {
const editorRect = await editor.boundingBox();
console.log('Editor rect:', JSON.stringify(editorRect));
const paragraphs = await editor.$$('p');
console.log('Paragraph count:', paragraphs.length);
if (paragraphs.length > 0) {
const pRect = await paragraphs[0].boundingBox();
console.log('First P rect:', JSON.stringify(pRect));
// Hover over paragraph at the left edge area
await page.mouse.move(pRect.x + 5, pRect.y + pRect.height/2);
await page.waitForTimeout(800);
const handleShell = await page.$('[data-testid="mnote-leptos-tiptap-handle"]');
console.log('Handle shell found:', !!handleShell);
if (handleShell) {
const hRect = await handleShell.boundingBox();
console.log('Handle rect:', JSON.stringify(hRect));
const display = await handleShell.evaluate(el => getComputedStyle(el).display);
const visibility = await handleShell.evaluate(el => getComputedStyle(el).visibility);
const opacity = await handleShell.evaluate(el => getComputedStyle(el).opacity);
const pointerEvents = await handleShell.evaluate(el => getComputedStyle(el).pointerEvents);
console.log('CSS: display=' + display + ' vis=' + visibility + ' op=' + opacity + ' pe=' + pointerEvents);
await page.screenshot({ path: '/tmp/handle-shell-found.png', fullPage: false });
const trigger = await handleShell.$('[data-testid="block-drag-handle-trigger"]');
console.log('Trigger found:', !!trigger);
if (trigger) {
const tRect = await trigger.boundingBox();
console.log('Trigger rect:', JSON.stringify(tRect));
// Click
await trigger.click({ force: true });
await page.waitForTimeout(1000);
const blockMenu = await page.$('[data-testid="block-drag-menu"]');
console.log('Block menu found:', !!blockMenu);
if (blockMenu) {
const bmRect = await blockMenu.boundingBox();
console.log('Block menu rect:', JSON.stringify(bmRect));
const menuItems = await blockMenu.$$('[data-testid^="block-drag-menu-item"]');
console.log('Menu items count:', menuItems.length);
const iconSpans = await blockMenu.$$('.block-drag-menu-icon');
console.log('Icon spans count:', iconSpans.length);
for (const item of menuItems) {
const text = await item.textContent();
const icon = await item.$('.material-symbols-outlined');
const icon2 = await item.$('[data-testid^="block-drag-menu-icon"]');
console.log(' Item text="' + (text ? text.trim().substring(0,40) : '') + '" icon=' + !!icon);
}
await page.screenshot({ path: '/tmp/block-menu-open.png', fullPage: false });
} else {
// Check what's rendered
const stage = await page.$('[data-testid="mnote-leptos-tiptap-editor-stage"]');
const stageHTML = await stage.evaluate(el => el.innerHTML.substring(0, 3000));
console.log('Stage HTML after click (3K):', stageHTML.substring(0, 1000) + '...');
// Check for menu elements anywhere
const allMenus = await page.$$('[class*="menu"]');
console.log('Any menu elements on page:', allMenus.length);
}
}
} else {
// Check stage innerHTML
const stage = await page.$('[data-testid="mnote-leptos-tiptap-editor-stage"]');
console.log('Stage found:', !!stage);
if (stage) {
const html = await stage.evaluate(el => el.innerHTML.substring(0, 2000));
console.log('Stage HTML:', html);
}
}
}
}
console.log('Console errors:', consoleErrors.length);
console.log('Page errors:', testErrors.length);
await browser.close();
})().catch(e => { console.error('CRASH:', e.message); process.exit(1); });