feat: purge legacy agent hosts and land vault Chrome extension path

Retire ACP/Hermes/OpenCode surfaces and rename hermes_tools to
mnote_agent_tools so Page AI stays on Pi Lab only. Add Chrome vault
extension + extension token route, pre-release purge design, and soft-retire
legacy smokes for the small-group production cut.
This commit is contained in:
Agent Board
2026-07-25 14:25:37 +08:00
parent bc6f8488ee
commit 262e66b02e
137 changed files with 9018 additions and 46049 deletions
+58 -56
View File
@@ -2,13 +2,28 @@
## 当前主线
- 方向`tree-first graph kernel` + local-first MVP 后阶段(底座统一、compat 瘦身、产品化闭环)。
- 产品形态:`VSCode 简化版工作区 + leptos-tiptap Markdown + Pi Rust Page AI + LightRAG + mindmap/office 插件 + Wolai 主题壳 + Turso/libSQL 控制面`
- 数据真相:本地 workspace folder;页面正文为 `.md`控制面:Turso/libSQLauth / membership / share / sync / AI policy / Pi scope)。
- Page AIPi Lab 为 3000 默认且唯一产品级入口。ACP/Hermes/Reasonix、OpenHub、旧 `/api/hermes/*` 等为兼容/legacy,不得新增能力;退役须原子覆盖
- Web`mnote-web` 为 3000 唯一公开入口。kernel 持有树/边/projection/command 语义;前端只消费稳定 projection。
- 正文保存:`page.body.write` + 文件版本;AI 普通 Markdown:授权文件 + allowed roots + Pi patch/diff + watcher。知识库:LightRAG + `mnote.knowledge_rag.*`
- 架构正文:只读 `ARCHITECTURE.md``CURRENT_ARCHITECTURE.md` 为兼容指针)。执行稿:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`
- 产品`tree-first graph kernel` + local-first 工作区;云首发同栈(单机/云服务器均可部署)。
- 形态:`VSCode 简化版工作区 + leptos-tiptap Markdown + Pi Lab Page AI + LightRAG + mindmap/OnlyOffice + Wolai 主题壳 + libSQL 控制面 + Vault`
- 数据真相:本地(或服务器上的)workspace folder;页面正文为 `.md`
- 控制面:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` 为默认与云首发推荐(独立部署、无需 Turso 云账号);`turso-remote` / `turso-local-replica` 仅在明确需要托管同步时使用。`sqlite` 不是 mnote-web 运行时
- Web`mnote-web`**3000** 唯一公开入口。kernel 持有树/边/projection/command;前端只消费稳定 projection。
- Page AI**Pi Lab**`/api/page-ai/pi/*`)为唯一产品入口
- 知识库:LightRAG + `mnote.knowledge_rag.*` / `/api/knowledge-rag/*`
- 正文保存:`page.body.write` + 文件版本;AI 写文件:授权 scope + allowed roots + Pi patch/diff + watcher。
- 架构 SSOT`ARCHITECTURE.md`。预发瘦身清单:`design/10-review/process/22-pre-release-legacy-purge-v1.md`。产品化收口:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`
## 云首发范围(必须保留)
| 能力 | 入口 / 说明 |
|------|-------------|
| 工作区 + 树 + 文档 | kernel + FileTree/PageTree + leptos-tiptap |
| Page AI | Pi Lab |
| 知识库 | LightRAG |
| 办公文档 | OnlyOffice(附件/资源进入) |
| 密码箱 | Vault + `$mnote-vault` |
| 控制面 | `libsql-local`auth / membership / share / AI policy / Pi scope |
不在首发产品面、且不得在文档中当作可选主链宣传的路径:旧 agent 网关、第三方 ACP 宿主、OpenCode 代理壳、debug tree shell(默认关闭)。清理顺序与可删文件见 purge 清单。
## 组件定位
@@ -18,17 +33,17 @@
| Mindmap | tree-first 视图/挂件;非真相层 |
| OnlyOffice | 独立页面编辑器;经附件/资源进入 |
| LightRAG + Pi | 知识库与 Page AI 默认融合 |
| Vault | 本机/服务器密码箱;AI 经 capability token 读密 |
| Resource → File → Page Tree | 对象真源 → 主组织投影 → 导航投影 |
## Runtime 模块定位
- Browser`rust/crates/mnote-web/browser/`
- Sidebar`sidebar-tree-runtime.js`(及 workspace / page tree / filetree / upload 等子模块
- FileTree`filetree-runtime.js``filetree-selection-runtime.js``filetree-context-menu-runtime.js``filetree-dnd-runtime.js``filetree-keyboard-runtime.js`
- Page AI host`sidebar-page-ai-runtime.js`owner `07-ai`
- Sidebar`sidebar-tree-runtime.js`(及 workspace / page tree / filetree / upload 等)
- FileTree`filetree-*.js`
- Page AI host`sidebar-page-ai-*.js`(默认 Pi Lab
- 文档 host`document-editor-adapter-runtime.js`
- debug tree shell`tree-shell-runtime.js`(默认关闭)
- Island`rust/spikes/leptos-tiptap-spike/src/editor_runtime/`(勿把新行为继续堆进根 `lib.rs`,除非 wasm entry / 薄壳)
- Island`rust/spikes/leptos-tiptap-spike/src/editor_runtime/`
- SSR 注入:`layout.rs` / `tree.rs` / `web_shell.rs` — 非大型 JS 功能定位入口
## 目录优先级
@@ -37,84 +52,71 @@
|------|------|
| `rust/crates/core-protocol/` | Kernel 类型、projection 协议 |
| `rust/crates/bridge-runtime/` | query / command / traversal |
| `rust/crates/mnote-web/` | 3000 route、transport、projection、compat |
| `rust/crates/mnote-web/` | 3000 route、transport、projection |
| `rust/crates/mnote-web/browser/` | 浏览器 runtime JS |
| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island |
| `wolai-backend/app/` | 辅助后端 / 异步 |
| `rust/crates/mnote-vault-core/` | Vault 核心 |
| `src/components/onlyoffice/` | OnlyOffice 静态与插件 |
| `recycle/` | 历史回收;默认不作为实现依据 |
| `wolai-backend/app/` | 辅助后端 / 异步(非 3000 主入口) |
| `recycle/` | 回收区;不作为实现依据 |
| `reference-code/` | 对照参考;**不进部署包** |
## 架构约束
- 树 / 页面结构 / 引用边:优先落 Rust kernel,禁止 UI 拼第二套真相。
- `compat route` 只承接过渡流量,不堆长期业务
- 浏览器主链禁止新增 `setInterval` / 周期轮询刷新(树、文档、附件、AI 面板等);用 WS/SSE、watcher、command result、`MutationObserver`。legacy/debug 若保留轮询须写明原因与退出条件
- 标题 / 页面设置 / 正文:收口 Page Aggregate;不在壳外侧拼第二份页面真相
- 新建/重命名/移动/归档:`tree.*``documents.*` 仅兼容
- local-first AI 正文:文件引用 + `AiAccessScope` + Pi patch/diff + 版本冲突 + watcher`mnote.doc.*` / `mnote.block.*` 仅 cloud/remote/compat 或结构辅助。Phase C 流式 apply 冻结
- Page AI / 知识库首屏:对话与任务区优先;debug/health 放折叠区
- Page AI 宿主改原生 Pi Lab runtime;禁止 DOM 注入伪装第三方;普通 UI 不得出现 fallback 切换入口
- 知识库:LightRAG + `mnote.knowledge_rag.*` / `/api/knowledge-rag/*`
- 架构判断优先:`ARCHITECTURE.md`,再查对应 `design/**` process/done 稿。
- 树 / 页面结构 / 引用边:落 Rust kernel,禁止 UI 拼第二套真相。
- 浏览器主链禁止新增 `setInterval` / 周期轮询;用 WS/SSE、watcher、command result、`MutationObserver`
- 标题 / 页面设置 / 正文:收口 Page Aggregate
- 新建/重命名/移动/归档:`tree.*`
- AI 正文:文件引用 + `AiAccessScope` + Pi patch/diff + 版本冲突 + watcher。Phase C 流式 apply 冻结
- Page AI 宿主为原生 Pi Lab runtime;普通 UI 不得出现第二套 agent 切换入口
- Agent 工具包:`rust/crates/mnote-web/src/mnote_agent_tools/`HTTP 面 `/api/mnote/tools/*`Pi / LightRAG / skill / OnlyOffice live tools 依赖,**不可整目录误删**)
- 架构判断:`ARCHITECTURE.md` → 对应 `design/**` process/done 稿
## 设计稿目录规则
- `design/01-*``design/10-*``process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。
- 已完成主线稿必须迁入 `done/`;被覆盖且非 done 的旧稿迁 `design/old/` 并标 `[recycle]`
- `design/01-*``design/12-*``process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。
- 已完成主线稿 `done/`;被覆盖旧稿迁 `design/old/` 并标 `[recycle]`
- `design/90-reference/` 只放参考资料。
## Bugs 目录规则
- 镜像 design 大类;`process/` 未修完,`done/` 已验证。
- 按真正 owner 归类:壳/Sidebar/文档页体验 → `05-editor-mainline/`projection/selection/DnD/tree command `04-tree-domain/`
- MVP 阶段发现 bug 时判断是否系统缺口;边界不清先问用户。
- 壳/Sidebar/文档页体验 → `05-editor-mainline/`projection/selection/DnD/tree → `04-tree-domain/`
## 协作边界
- 只改与任务直接相关的文件;不覆盖用户已有改动;无关脏改动保持不动。
- UI 异常先查 debug shell / compat / 轮询是否混入主链
- 发现轮询先评估事件驱动替代,再决定是否保留临时 fallback
- Subagent 为受控叶子:任务书限定读写范围、验证命令、交付与禁止项;不得再启 runner/worktree 或改任务书。主控复核 diff/证据后才采纳。
- 同一写入范围同时只允许一个 writer(主控或一个 worker);写入范围失效须先取消 worker 再改。
- 最终测试/回复前确认实现型 subagent 已完成、已取消,或输出已标不采纳。
- Subagent 为受控叶子:限定读写范围与验证命令;主控复核 diff/证据后才采纳
- 同一写入范围同时只允许一个 writer
## CodeGraph
- 结构性代码问题优先 CodeGraph MCPsearch / callers / callees / impact / explore)。
- 结构性代码问题优先 CodeGraph MCPexplore / impact)。
- 改代码后 `codegraph sync .`;大重构或索引异常 `codegraph index . --force`
- 提交前再 sync,确认无 pending字面文本/日志字符串优先 `rg`
- 字面文本/日志字符串优先 `rg`
## Reference-Code
- 优先 `reference-code/sidex-main`(完整 VSCode 工作台)`reference-code/vscode` 为裁剪辅助。
- 每次只对照一个切面;区分可直接采用的交互模型 vs 不符合 local-first/tree-first 的细节
- 不要把参考项目 UI 状态当成 MNote 新事实源。
- 优先 `reference-code/sidex-main``reference-code/vscode` 为裁剪辅助。
- 每次只对照一个切面;不要把参考项目 UI 状态当成 MNote 新事实源
## 常用命令
- 热启动:`npm run desktop:hot``http://localhost:3000`
- 测试:`cargo test -p mnote-web`
- 后端:`cd wolai-backend && uvicorn app.main:app --reload --port 8000`
- Control-plane:默认 `MNOTE_CONTROL_PLANE_BACKEND=libsql-local`;云端用 `turso-remote` / `turso-local-replica` + URL/token。`sqlite` 非 mnote-web 运行时。
- Control-plane 默认:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local`
- 云首发:同 `libsql-local` + 服务器本地数据目录;需要托管再换 `turso-remote`
## Smoke 与测试账号
- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + Turso/libSQL auth`
- 禁止 smoke 用 `sqlite3` CLI 直写 control-planeseed 走 Rust API / `scripts/lib/control-plane-dev-seed.js` / `control-plane-test-env.js`
- 测试账号:`mnote.e2e@example.com` / `MnoteE2E123!` / `mnote-e2e`;优先 `/auth`「测试账号快速登录」,勿用 `MNOTE_DEV_AUTH=1` 跳过真实 auth
- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + libSQL auth`
- 禁止 smoke 用 `sqlite3` CLI 直写 control-planeseed 走 Rust API / `scripts/lib/control-plane-dev-seed.js`
- 测试账号:`mnote.e2e@example.com` / `MnoteE2E123!` / `mnote-e2e`;优先 `/auth`「测试账号快速登录」。
## 前端测试
- 真实渲染:`/doko`;交互回归:浏览器自动化。
- 真实渲染:`/doko`;交互回归:浏览器自动化。
- 主页/Sidebar/文档首屏优先复用 `scripts/task*-smoke.js`
- 高 CPU/内存:先查 debug shell、compat fallback、重复请求、轮询。
## Wolai-aline
- 启用 wolai-aline skill + `design/08-wolai-aline-test-flow/reference/wolai-aline-test-flow-v1.md`
- 流程:Wolai 基线(默认只读)→ 本地 RED smoke → 实现 → 验证 → subagent 浏览器对标 → 截图复核。
- 浏览器对标用 subagent,只验证与截图;写入需明确授权与沙盒页。
- 登录态:`tmp/wolai-playwright-profile`;滑块验证不绕过,记录阻塞。
## 编码
@@ -122,6 +124,6 @@
## 密码箱 / agent skills(薄指针)
- 密码箱策略 SSOT`/home/lix/.agent-infra/vault-policy.md`skill `$mnote-vault``skills/mnote-vault`,全局 symlink 到 Codex/Hermes/Grok)。
- Page AI pack 注册:`hermes_tools/skill.rs` id `mnote-vault`(仅放 `skills/` 不够)。
- 勿在本文件复制完整 vault 策略Paseo 经 `daemon.appendSystemPrompt` 全 agent 注入短段
- 策略 SSOT`/home/lix/.agent-infra/vault-policy.md`skill `$mnote-vault``skills/mnote-vault`)。
- Page AI pack 注册:agent tool pack 内 `skill` 模块 id `mnote-vault`(仅放 `skills/` 不够)。
- 勿在本文件复制完整 vault 策略。
+83 -87
View File
@@ -1,113 +1,116 @@
# MNote 架构
> 更新时间:2026-07-19
> 本文只描述**当前**产品形态运行分层与收口缺口。历史迁移、退役路径与 changelog 不在此展开;协作规则见 `AGENTS.md`,执行 checklist 见 `design/`。
> 更新时间:2026-07-25
> 本文只描述**当前**产品形态运行分层。协作规则见 `AGENTS.md`;预发瘦身见 `design/10-review/process/22-pre-release-legacy-purge-v1.md`;产品化收口见 `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。
## 1. 产品形态
```text
MNote = VSCode 简化版工作区
+ tiptap Markdown 编辑器(leptos-tiptap island
+ Pi Rust Page AI
+ Pi Lab Page AI
+ LightRAG 知识库
+ simplemindmap / OnlyOffice 插件
+ simplemindmap / OnlyOffice
+ Vault 密码箱
+ Wolai 主题 Web 壳
+ Turso/libSQL 鉴权控制面
+ libSQL 控制面(默认 libsql-local
```
| 层 | 当前真源 / Owner |
|----|------------------|
| 工作区数据 | 本地 workspace folder;页面正文为 `.md` |
| 工作区数据 | workspace folder;页面正文为 `.md` |
| 树 / 资源语义 | Rust kernel`core-protocol` + `bridge-runtime` |
| Web 入口 | `mnote-web`,公开端口 **3000** |
| 控制面 | Turso/libSQLauth、membership、share、sync state、AI policy / Pi scope |
| Page AI | Pi Lab`/api/page-ai/pi/*`,默认唯一产品级入口 |
| 知识库 | LightRAG + `mnote.knowledge_rag.*` facade |
| 编辑投影 | Page Aggregate`mnote.page_aggregate.v1`);tiptap 为显示层,非对象真源 |
| 控制面 | libSQL/Turso storeauth、membership、share、sync state、AI policy / Pi scope |
| Page AI | Pi Lab`/api/page-ai/pi/*` |
| 知识库 | LightRAG + `mnote.knowledge_rag.*` |
| 密码箱 | Vaultstore + web routes + extension/agent token |
| 编辑投影 | Page Aggregate`mnote.page_aggregate.v1`);tiptap 为显示层 |
一句话:
> **本地文件夹是数据真相,Rust kernel 持有语义,Turso/libSQL 持有控制面,前端只消费稳定 projection。**
> **工作区文件夹是数据真相,Rust kernel 持有语义,libSQL 持有控制面,前端只消费稳定 projection。**
### 1.1 云首发
- 与本地同栈:Pi Lab、LightRAG、OnlyOffice、Vault、树/文档主链。
- 控制面推荐 **`libsql-local`**:服务器上独立文件库,不依赖外部 Turso 账号;运维简单、可备份导出。
- `turso-remote` / replica:可选增强,不是首发前置条件。
- 部署包不含 `reference-code/``recycle/`、debug shell 默认路由。
## 2. 分层
### 2.1 Workspace / Storage
- 默认 source`local_folder`
受管根:`/mnt/Data1T/Mnote_data/users/<actor>/workspaces/my-space/`
- 管理员通过控制面授权可读写目录;普通用户不能自助全盘读写
- 页面内上传默认写入 sibling assets(如 `README.assets/image.png`),正文存相对 Markdown 链接
- 本地 `.md` / 附件 / mindmap / OnlyOffice **不**迁入 control-plane DB
- 默认 source`local_folder`(本机或服务器磁盘路径)
- 管理员通过控制面授权可读写目录
- 页面内上传默认 sibling assets;正文存相对 Markdown 链接
- `.md` / 附件 / mindmap / OnlyOffice **不**迁入 control-plane DB
### 2.2 Kernel / Projection
- `core-protocol`:树、资源、页面、AI access scope、page body write 等协议
- `bridge-runtime` + `mnote-web`LocalFS(及显式 cloud/compat source→ 稳定 projection / command
- 前端消费:`file_tree``page_tree``page_aggregate`、tree command result、少量 editor runtime payload
树域三层:
- `bridge-runtime` + `mnote-web`LocalFS → 稳定 projection / command
- 前端消费:`file_tree``page_tree``page_aggregate`、tree command result
| 层 | 角色 |
|----|------|
| Resource Tree | kernel 对象组织真源page / mindmap / attachment / onlyoffice / …) |
| File Tree | 主组织投影;页面正文行为 `{title}.md` |
| Resource Tree | kernel 对象组织真源 |
| File Tree | 主组织投影;页面正文 `{title}.md` |
| Page Tree | 导航投影;不持有结构真相 |
正式命令面:`tree.*`(如 `tree.node.create` / `rename` / `tree.subtree.move`)。`documents.*` 仅为兼容层
正式命令面:`tree.*`
### 2.3 Web Shell / Editor
- Owner`mnote-web`Rust SSR + browser runtime
- 文档页默认编辑 host:页面内 `leptos-tiptap` island
- browser JS`rust/crates/mnote-web/browser/*.js`
- island`rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`
- `layout.rs` / `tree.rs` / `web_shell.rs` 只做 SSR / bootstrap / route / asset 注入
- 正文保存主入口:`page.body.write` / `/api/page-body/write`(带文件版本);`/api/documents/save` 为 compat
- Watcherclean editor 自动刷新;dirty editor 进入冲突态,不静默覆盖
- RealtimeWS 主链 `/api/realtime/ws` + SSE fallback `/api/tree/events`snapshot / delta / resync
- Mindmaptree-first 的视图 / 挂件,非对象真源
- OnlyOffice:独立页面型编辑器,经附件/资源跳转进入
- 文档页`leptos-tiptap` island + `browser/*.js`
- 正文保存:`page.body.write` / `/api/page-body/write`(文件版本)
- Watcherclean 自动刷新;dirty 冲突态,不静默覆盖
- RealtimeWS `/api/realtime/ws` + SSE `/api/tree/events`
- Mindmap:视图/挂件;OnlyOffice:独立编辑器经资源进入
### 2.4 Page Aggregate
- 读取主链:Rust `mnote.page_aggregate.v1` snapshot`/api/page-aggregate/:id`
- 输出含 `blockDocument` / `blockProjectionVersion` / `projectionSource`
- 本地正文真相是 `.md`;aggregate 是投影,不是第二份正文库
- 仍属过渡态:block projection 多从 markdown content 投影;客户端仍有 `PageAggregateClientState` reducer
- 读取`mnote.page_aggregate.v1``/api/page-aggregate/:id`
- 本地正文真相是 `.md`aggregate 是投影
### 2.5 Page AIPi Rust
### 2.5 Page AIPi Lab
```text
当前页 → 真实 .md
→ AiAccessScope / allowed roots / selection
→ Pi Rust Page AI/api/page-ai/pi/*
→ agent 原生 patch/diff 写文件
→ watcher / BufferStore / refresh → Page Aggregate + tiptap
→ Pi Lab/api/page-ai/pi/*
→ agent patch/diff 写文件
→ watcher / BufferStore → Page Aggregate + tiptap
```
- Pi session 元数据 / run / tool event 默认落 control-planePi JSONL 为 runtime 工作副本
- `mnote.doc.*` / `mnote.block.*` / `mnote.page.*`cloud / remote / compat / 复杂结构辅助
- `/api/page-ai/block-edit-workflow`:非 local-first 默认正文路径
- 流式 apply + suggest/review**Phase C 冻结,当前不实施**
- 遗留 ACP / Hermes / Reasonix / OpenCode:兼容面,默认主壳不注入;不得新增能力;退役须原子覆盖 sidebar、route、runtime、wrapper`hermes_tools` 中 provider-neutral 契约按实际消费者保留)
- Session / run / tool event 默认落 control-planePi JSONL 为 runtime 工作副本
- Agent 工具 HTTP`/api/mnote/tools/{manifest,call,audit}`
- 工具实现目录:`rust/crates/mnote-web/src/mnote_agent_tools/`
- Pi 直接依赖其中 `knowledge_rag` 的 agent 输出整形,以及 skill pack(含 `mnote-vault`
- 流式 apply + suggest/reviewPhase C 冻结
### 2.6 Knowledge RAGLightRAG
- 资料 ingestion / query / citation / open-reference 主路径:LightRAG
- MNote 维护 source registry、权限、FileTree 灯号、status UI、citation 映射、tool facade
- Agent 工具:`mnote.knowledge_rag.status` / `query` / `open_reference`
- 不把 provider storage 当用户正文真相;不复制图谱/chunk 为第二套索引
- ingestion / query / citation / open-reference 主路径:LightRAG
- MNote 维护 source registry、权限、FileTree 灯号、status UI、citation 映射、tool facade
- Agent 工具:`mnote.knowledge_rag.status` / `query` / `section_context` / `open_reference`
### 2.7 Control-plane
### 2.7 Vault
- 服务端 store + `/api/vault/*` 工作台;extension/agent 经 capability token
- 策略与 agent 用法:`/home/lix/.agent-infra/vault-policy.md``$mnote-vault`
### 2.8 Control-plane
```text
ControlPlaneStore
── TursoControlPlaneStore mnote-web 唯一运行时
├── libsql-local 默认
├── turso-remote
├── turso-local-replica
└── turso-synced
└── SqliteControlPlaneStore 仅 admin 迁移/导出与测试隔离
── TursoControlPlaneStore mnote-web 唯一运行时
├── libsql-local 默认 / 云首发推荐
├── turso-remote 可选托管
├── turso-local-replica
└── turso-synced
```
| 变量 | 用途 | 默认 |
@@ -116,59 +119,52 @@ ControlPlaneStore
| `MNOTE_TURSO_LOCAL_PATH` | local 文件 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db` |
| `MNOTE_TURSO_DATABASE_URL` | remote URL | — |
| `MNOTE_TURSO_AUTH_TOKEN` | remote token | — |
| `MNOTE_TURSO_LOCAL_REPLICA_PATH` | replica 路径 | `…/control-plane-replica.db` |
| `MNOTE_TURSO_SYNCED_PATH` | synced 路径 | `…/control-plane-synced.db` |
| `MNOTE_TURSO_SYNC_INTERVAL_MS` | sync 间隔 | 未设置 |
- 只存元数据:users、sessions、workspaces、directory grants、share、sync state、AI policy/runtime、audit
- `sqlite` 不得作为 mnote-web runtime;启动链拒绝
- 备份:`control-plane-admin export-target-to-sqlite` → 再导入 libSQL/Turso不回退 SQLite 运行
- `sqlite` 不得作为 mnote-web runtime
- 备份:admin export → 再导入 libSQL不回退 SQLite 运行
### 2.8 代码入口
### 2.9 代码入口
| 路径 | 职责 |
|------|------|
| `rust/crates/core-protocol/` | Kernel 类型与 projection 协议 |
| `rust/crates/bridge-runtime/` | query / command / traversal |
| `rust/crates/mnote-web/` | 3000 gateway、SSR shell、API、compat、realtime |
| `rust/crates/mnote-web/browser/` | Sidebar / FileTree / document adapter / Page AI host JS |
| `rust/spikes/leptos-tiptap-spike/` | 文档页默认编辑 island |
| `wolai-backend/app/` | 辅助后端 / 异步处理 |
| `src/components/onlyoffice/` | OnlyOffice 静态与插件资源 |
`recycle/` 为历史回收区,不作为当前实现依据。
| `rust/crates/mnote-web/` | 3000 gateway、SSR、API、realtime |
| `rust/crates/mnote-web/browser/` | Sidebar / FileTree / document / Page AI host |
| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island |
| `rust/crates/mnote-vault-core/` | Vault 核心 |
| `src/components/onlyoffice/` | OnlyOffice 静态资源 |
## 3. 当前收口缺口
阶段:local-first **MVP 后**。底座与主路径已立;重点是统一与产品化,不是扩 compat 或旧主链
阶段:local-first **MVP 后**;重点是统一与产品化,以及预发前去掉非首发代码面
1. **WorkspacePath / ObjectIdentity** — 路径identity、资源归属统一到 kernel contract
2. **DocumentBuffer / BufferStore**runtime 已有;多 tab 仲裁与冲突 UI 体验收口
3. **Page Aggregate**瘦身 compat join;退役 ClientState 混合 reducer以 Rust snapshot 为单一运行时真相
4. **tree command context** — 菜单 / 快捷键 / AI enablement 同一 context,勿各处硬编码
5. **tree live cache** — watcher + WS/SSE 收进同一 cache,减补偿链
6. **管理员目录授权 UI** — grant / access-policy 可管理
7. **冲突合并 UI** — 接受磁盘 / 保留编辑器 / diff 合并
8. **agent 写入审计** — changed files、diff、run id、actor 本地落盘,可同步控制面
9. **本地轻量搜索** — workspace 全文 / 反链 / 标签,不依赖 cloud search
10. **分享与同步闭环** — share grant ≠ 本机 filesystem root;控制面不可用时不扩权限
1. **WorkspacePath / ObjectIdentity** — 路径identity 统一到 kernel
2. **DocumentBuffer / BufferStore** — 多 tab 仲裁与冲突 UI
3. **Page Aggregate** — 以 Rust snapshot 为单一运行时真相
4. **tree command context** — 菜单 / 快捷键 / AI enablement 同一 context
5. **tree live cache** — watcher + WS/SSE 收进同一 cache
6. **管理员目录授权 UI**
7. **冲突合并 UI**
8. **agent 写入审计**
9. **本地轻量搜索**
10. **分享与同步闭环**
11. **插件资源模型** — mindmap / office 作为 Resource Tree 对象
12. **遗留兼容面退役**ACP/Hermes/Reasonix/OpenCode 原子删除;Convex 仅显式 cloud/compat
执行稿:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`
12. **预发瘦身**删除非 Pi 的 agent 宿主与巨型兼容网关;工具包重命名;见 `22-pre-release-legacy-purge-v1.md`
## 4. 设计索引(当前)
| 主题 | 文档 |
|------|------|
| 预发瘦身 | `design/10-review/process/22-pre-release-legacy-purge-v1.md` |
| 总收口 | `design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md` |
| tree-first kernel | `design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md` |
| local-first + control-plane | `design/02-convex-rust-long-term-architecture/done/2-2-*.md``process/2-9-turso-control-plane-cutover-v1.md` |
| Page Aggregate | `design/05-editor-mainline/reference/5-5-*.md``done/5-6-*.md` |
| control-plane | `design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md` |
| Page Aggregate | `design/05-editor-mainline/` |
| AI 文件编辑 | `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` |
| LightRAG | `design/07-ai/done/7-50-lightrag-knowledge-rag-provider-v1.md` |
| tree command / realtime | `design/04-tree-domain/done/4-6-*.md``design/03-rust-web/done/3-3-*.md``3-14-*.md` |
| Resource / File / Page Tree | `design/04-tree-domain/done/4-24-*.md` |
| Vault | `design/12-vault/` |
---
@@ -0,0 +1,222 @@
# 22 · 预发 Legacy 瘦身清单 v1
> 状态:**process → 执行完成(Wave 09 出口已达)**
> 日期:2026-07-25
> 目标:云首发前去掉非产品主链代码与叙事,减小二进制/认知负担;**不误伤 Pi Lab / LightRAG / OnlyOffice / Vault**。
> 控制面云首发:`libsql-local`(独立部署)。
> 产品 Page AI 唯一主链:**Pi LabRust host `page_ai_pi` + `packages/pi-mnote`**。
> 关联:`AGENTS.md`、`ARCHITECTURE.md`、`21-mvp-post-architecture-closure-checklist-v1.md`
## 0. 原则
1. **产品面只保留**:树/文档、Pi Lab、LightRAG、OnlyOffice、Vault、libSQL 控制面。
2. **文档不再写**「OpenHub / Hermes / ACP / Reasonix / OpenCode 是兼容选项」类历史叙事;清理任务只写在本清单。
3. **原子删除**route + `lib.rs`/`mod.rs` 注册 + browser 引用 + 测试 fixture 同批;禁止半删导致编译红。
4. **先重命名再删依赖名**`hermes_tools`**Pi 仍在用的 agent tool pack**,不是整包可删垃圾。
5. **不动**用户进行中的 Vault WIP、无关脏改动。
6. 影响面用 **CodeGraph** + `rg`;大块删除后 `cargo test -p mnote-web` + 主链 smoke。
## 1. 体量快照(2026-07-25 本机)
| 路径 | 约行数 / 体积 | 处置 |
|------|----------------|------|
| `routes/hermes_client.rs` | ~18.7k 行 / ~688KB | **DELETED** |
| `routes/hermes_tools.rs` | ~6.8k 行 / ~272KB | **RENAMED**`routes/mnote_tools.rs` |
| `src/hermes_tools/` | ~412KBtool pack | **RENAMED**`src/mnote_agent_tools/` |
| `routes/hermes.rs` | ~297 行 | **DELETED** |
| `acp_*.rs`5 文件) | ~4.4k 行合计 | **DELETED** |
| `routes/page_ai_opencode.rs` | ~1.1k 行 | **DELETED** |
| `routes/page_ai_pi/` | 主链 | **KEEP** |
| `routes/knowledge_rag.rs` | 主链 | **KEEP** |
| `routes/vault*.rs` | 主链 | **KEEP**(含进行中 extension token |
| `reference-code/` | ~5GB 级 | **不进部署**;仓库可保留对照 |
| `recycle/` | 历史 | **不进部署** |
## 2. 路由面
### 2.1 正式保留(产品)
| 前缀 / 路径 | 说明 |
|-------------|------|
| `/api/page-ai/pi/*` | Pi Lab |
| `/api/mnote/tools/{manifest,call,audit}` | Agent 工具(中性名) |
| `/api/knowledge-rag/*` | LightRAG |
| `/api/vault/*` | Vault |
| `/api/page-body/*``/api/page-aggregate/*`、tree/realtime 等 | 文档与树主链 |
| `/api/onlyoffice/*` | OnlyOffice |
### 2.2 已删除(非首发)
| 前缀 / 路径 | 实现 | 状态 |
|-------------|------|------|
| `/api/hermes/*`(含 client/sessions/runs/…) | `hermes.rs` + `hermes_client.rs` | **DELETED** |
| `/api/hermes/tools/*` | legacy alias | **DELETED**(仅留 `/api/mnote/tools` |
| `/api/page-ai/opencode/*``/page-ai/opencode` | `page_ai_opencode.rs` | **DELETED** |
| hermes_client 独占 page-ai sessions/runs | `hermes_client` | **DELETED**Pi 自有 session API 保留) |
| debug`/tree``/document-debug``/ui-debug/*` | flag `enable_debug_shell_routes` | 云默认 **false**;代码可保留 |
### 2.3 配置 / 状态字段
| 符号 | 处置 |
|------|------|
| `AppConfig.hermes_base_path` | 已随 hermes 删除 / 测试清理 |
| `AppState.acp_runtime` | **DELETED**(随 ACP |
| `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` | 云默认 false |
| `MNOTE_PAGE_AI_PI_LAB` | **保留**,云默认 true |
| `MNOTE_WEB_ACP_*` / `MNOTE_WEB_HERMES_*` / Reasonix wrapper | **DELETED** |
## 3. 源码文件处置
### 3.1 DELETED(整文件)
```text
rust/crates/mnote-web/src/routes/hermes_client.rs
rust/crates/mnote-web/src/routes/hermes.rs
rust/crates/mnote-web/src/routes/page_ai_opencode.rs
rust/crates/mnote-web/src/acp_bridge.rs
rust/crates/mnote-web/src/acp_client.rs
rust/crates/mnote-web/src/acp_runtime.rs
rust/crates/mnote-web/src/acp_session_manager.rs
rust/crates/mnote-web/src/acp_types.rs
scripts/reasonix-acp-wrapper.mjs
```
同步已改:`lib.rs``routes/mod.rs``app.rs`、相关测试 fixture。
### 3.2 RENAMED(不可整删)
| 现状 | 目标 | 状态 |
|------|------|------|
| `src/hermes_tools/` | `src/mnote_agent_tools/` | **DONE** |
| `routes/hermes_tools.rs` | `routes/mnote_tools.rs` | **DONE** |
| `crate::hermes_tools::` | `crate::mnote_agent_tools::` | **DONE** |
Pi 硬依赖(保留):
- `page_ai_pi/runtime.rs``mnote_agent_tools::knowledge_rag`
- `page_ai_workflow.rs``ToolCallInput` + `execute_mnote_tool_call`
- skill pack`mnote_agent_tools/skill.rs``mnote-vault` 等)
### 3.3 KEEP(首发)
```text
routes/page_ai_pi/
routes/knowledge_rag.rs
routes/vault.rs / vault_store.rs / vault_extension_token.rs / …
routes/onlyoffice*.rs
browser/sidebar-page-ai-pi-lab-runtime.js 及 Pi 相关 host
packages/pi-mnote/ (若部署需要)
```
### 3.4 Browser 清理(产品入口)
**产品唯一入口:Pi Lab**
| 路径 | 行为 |
|------|------|
| 浮钮 `data-mnote-action="open-page-ai-pi-lab"` | 只打开 Pi Lab |
| `sidebar-tree-runtime` `open-page-ai` / `open-page-ai-pi-lab` | `mnote:pi-lab-show`,不打开 legacy drawer |
| `openPageAiDrawer()` | **Pi-only stub**`mnote:pi-lab-show` |
| `sidebar-page-ai-runtime.js` | **已瘦身为 ~100 行 stub** |
| `sidebar-page-ai-{markdown,permission,profile,render,session,skill,target}-runtime.js` | **DELETED** |
| `agent-stream-event-router.js` | **DELETED** |
| page-settings 扩展面板 | Skills/Tools 读 `/api/mnote/tools/manifest`;已移除 MCP / hermes runs 面板与 `/api/hermes/*` fetch |
| document shell Hermes settings inject | **DELETED**`__mnoteHermesSettingsUrl` |
合同测试:`page_ai_product_entry_is_pi_lab_only` + 布局无 `open-page-ai` 旧 action。
### 3.5 脚本 / 包装
| 路径 | 处置 |
|------|------|
| `scripts/reasonix-acp-wrapper.mjs` | **DELETED** |
| 仅测 hermes/opencode 的 smoke | **Wave 10**:依赖 `/api/hermes/client` 或 OpenCode host 的 20 个 smoke 已 soft-retireexit 0 + 注释);工具类 smoke 改 `/api/mnote/tools/*` |
| OpenHub 负向检查 | 可保留「禁止生成 openhub 目录」类断言 |
| `task126` hermes bridge | 改为负向门禁:`/api/hermes/bridge` 404 + tools manifest 可达 |
| `task116` islands | AI bridge 检查改为 `/api/mnote/tools/manifest` |
## 4. 执行波次(结果)
| Wave | 内容 | 出口 |
|------|------|------|
| **0** | 本文清单 + 文档去历史叙事 | ✅ |
| **1** | agent/架构只描述当前主链 | ✅ AGENTS / ARCHITECTURE |
| **2** | RENAME `hermes_tools``mnote_agent_tools`;路由只挂 `/api/mnote/tools` | ✅ |
| **3** | DELETE `page_ai_opencode` + 代理 routes | ✅ |
| **4** | DELETE `acp_*` + Reasonix wrapper + env | ✅ |
| **5** | DELETE `hermes_client` + `hermes.rs` + `/api/hermes/*` | ✅ |
| **6** | browser 产品入口 Pi-only + 合同测试 | ✅ |
| **7** | 全量 `cargo test -p mnote-web` | ✅ 见 §5 |
| **8** | page-ai legacy 子模块 + agent-stream 物理删除;page-settings / document shell 去 Hermes | ✅ |
| **9** | agent tool schema / 头 / env / skill `agent_ids` 中性化 | ✅ |
| **10** | 活跃 smoke 去 `/api/hermes`;死 smoke soft-retirefixture 字面量中性化 | ✅ |
## 5. 验证门禁
- [x] `cargo test -p mnote-web`lib,含 tools / pi / knowledge_rag / vault / tree;见执行记录)
- [x] 启动默认:`libsql-local` + Pi Lab on`MNOTE_PAGE_AI_PI_LAB` 默认 true+ legacy compat off + debug shell off
- [x] 手工或 smoke:登录 → whoami → Pi status → tools manifest → LightRAG status → Vault list → hermes/opencode 4042026-07-25 本机 :3000
- [x] `rg` 主链产品入口无「切换到 Hermes/OpenCode」;浮钮 / tree / drawer 均 Pi Lab
- [x] 源码:`hermes_client` / `AcpRuntimeManager` / `page_ai_opencode` 文件与 route 注册已删除
- [x] 部署产物约定:不含 `reference-code/``recycle/`(仓库可保留对照)
- [x] Wave 8/9browser 死代码删除 + schema 中性化(`mnote.agent_tool*.v1`、skill 仅 `pi`/`chat_only`
- [x] Wave 10smoke 工具路径 → `/api/mnote/tools`hermes-client/opencode 死 smoke soft-retiresearch/evidence fixture 中性化
### 执行记录(2026-07-25
- `cargo test -p mnote-web --lib --test-threads=1`**849+ passed, 0 failed**
- 含合同 `page_ai_product_entry_is_pi_lab_only``mnote_tools_mount_is_current_only`
- 测试适配:lazy PageTree reveal、`watch_revision` 缓存键 canonicalize、layout `FILETREE_DND_RUNTIME_JS` 常量名
- 文件门禁:`hermes_client` / `hermes.rs` / `page_ai_opencode` / `acp_*` / `hermes_tools/` / `reasonix-acp-wrapper.mjs` 均已删除;`mnote_agent_tools` + `mnote_tools` 就位
- **Wave 8**page-ai 7 子模块 + agent-stream 物理删除;page-settings 去 `/api/hermes`document shell 去 Hermes settings inject`sidebar-page-ai-runtime.js` 瘦身为 Pi-only stub
- **Wave 9schema 中性化,已完成)**:
- `mnote.agent_tool_manifest.v1` / `mnote.agent_tool.v1`
- 响应头 `x-mnote-agent-tool-owner: mnote-web-agent-tools`
- 审计 env `MNOTE_AGENT_TOOL_AUDIT_LOG`(兼容旧 `MNOTE_HERMES_TOOL_AUDIT_LOG`
- profile home `MNOTE_AGENT_HOME`(兼容 `HERMES_HOME` / `~/.hermes`
- write meta`channel=agent``client=mnote-agent-plugin``refs=agent-tool-call`
- skill `agent_ids``pi` / `chat_only`
- **static smoke 适配**`task-pi-lab-static-smoke.js` 读取 `routes/page_ai_pi/{mod,constants,runtime}.rs` 目录模块(不再假定 monofile `page_ai_pi.rs`);legacy opencode host 断言改为 Pi-only stub
- **主链 E2E smoke(本机 :30002026-07-25**
- `node scripts/task-pi-lab-static-smoke.js`**270 checks passed**
- `node scripts/task-pi-lab-api-endpoint-smoke.js`**17/17 passed**
- 登录 `POST /api/auth auth:signIn` → whoami `mnote-e2e`
- Pi `GET /api/page-ai/pi/status` enabled + `mnote.page_ai_pi.status.v1`
- tools `GET /api/mnote/tools/manifest``mnote.agent_tool_manifest.v1` + owner `mnote-web-agent-tools`
- LightRAG `GET /api/knowledge-rag/status` 路由挂载 200(本机 daemon 9621 未起 → `lightrag_unreachable`API 面 OK
- Vault `GET /api/vault/list?rootUri=…` 200
- 负向:`/api/hermes/status``/api/page-ai/opencode/status` → 404
- **可选清理(本批)**:删除 `page-ai.css` reasonix/opencode 死样式;测试 fixture `reasonix``pi`
- **Wave 10smoke / fixture 清理,2026-07-25**
- 13 个工具 smoke`/api/hermes/tools/mnote/{call,manifest}``/api/mnote/tools/{call,manifest}`
- `task116`:检查 agent tools manifest(不再打 hermes bridge
- `task126``/api/hermes/bridge` 负向 404 + tools 可达
- 20 个依赖 hermes client / OpenCode host 的浏览器或静态 smoke**soft-retire**(保留文件、exit 0
- fixturesearch `Hermes``SkillGraph`evidence `@Reasonix``@AtlasNote`ai_settings skill 示例路径 `.hermes``.mnote/agent-profiles`
- 保留:负向合同(compat hermes provider)、`HERMES_HOME`/`~/.hermes` 兼容读、`.reasonix`/`.opencode` 目录忽略、`mnote-hermes-tool` 历史 source 兼容
- **Wave 10.1desktop-hot 去 OpenCode2026-07-25**
- `scripts/desktop-hot.js`:删除 `ENABLE_OPENCODE` / `OPENCODE_CMD` / `SKIP_OPENCODE` / `buildDefaultOpencodeCommand` / `shouldStartOpencode` / opencode task / `MNOTE_OPENCODE_BASE_URL`
- `scripts/dev-hot.js`:不再注入 `MNOTE_OPENCODE_BASE_URL`
- 测试:`desktop-hot.test.js``task-dev-hot-plan-test.js` 断言无 OpenCode 分支
## 6. 明确不在本清单
- Vault 功能增强 / extension`design/12-vault` 进行中工作保持不动;本批只做可达性 smoke)
- LightRAG provider 替换
- Phase C 流式 apply
- 把 control-plane 强行改成 turso-remote(云首发默认 **libsql-local**
- 删除 `design/old` 历史稿(可选后续,非阻塞)
- 测试函数名仍可带历史前缀(如 `hermes_tools_*`);**产品 schema 已中性化**,不必再以「schema 可另批」阻塞出口
- ~~可选清理:`page-ai.css` 内 reasonix/opencode 死样式、测试 fixture 字面量中性化~~ **本批已做**CSS 删 reasonix/opencode 死块;dev_seed/mod/mnote_tools 测试字面量 `reasonix``pi``.reasonix` 目录忽略保留)
## 7. CodeGraph / 残留备忘
- `ToolCallInput`:在 `mnote_agent_tools`
- `page_ai_pi/runtime.rs` 绑定 `mnote_agent_tools::knowledge_rag`
- 测试函数名仍可带 `hermes_tools_*` 前缀(历史命名,非产品路由)
- 正式 tools **仅** `/api/mnote/tools`
- 产品 Page AI**仅** `/api/page-ai/pi/*` + browser Pi Lab host
---
**状态**Wave 010 / 10.1 已完成;全量 lib 测试绿;主链 E2E smoke 绿;活跃 smoke 已切到 `/api/mnote/tools`,死 hermes/opencode smoke 已 soft-retiredesktop-hot 不再启动 OpenCode。
@@ -94,7 +94,8 @@ AI 不得用通用 file 工具扫 vault,只能走分层 `mnote.vault.*`P1
- 不做跨 workspace 云端 vault 同步。
- 不把 vault 做成 FileTree / Page Tree 节点或 `tree.*` 命令对象。
- 不把 vault CRUD 塞进 tiptap / Page Aggregate。
- 不做 OS 级自动填表扩展
- 不做 OS 级 / 浏览器原生级 **自动填表** 密码管理器(autofill 引擎非本设计目标)
- **Chrome「保存到密码箱」扩展**(登录/注册页确认后写入 vault)见独立稿:`design/12-vault/process/12-3-chrome-extension-vault-save-v1.md`12-3);与本 Non-Goal 不冲突——12-3 P0 仅保存、不做 autofill。
- **P0 不做** 外部进程改 vault 文件后的 live FS 刷新(见 §4.2);禁止 setInterval 伪装。
- 不把 vault 写入 LightRAG / evidence / 全局搜索 snippet。
- **不** 声称导入前 `个人/密码/` 已与 vault 同等安全。
@@ -223,6 +224,9 @@ accounts:
email: alice@example.com
password: <example-template>
passwordHint: "Li@[A]***"
# 登录态(Cookie)不写在本 frontmatter 新路径:
# 见 sessions/{id}/{accountId}.json12-3 §5.6);
# 旧字段 loginSession 仅兼容读。
# 附录密钥(API Key / Token);顶层 apikey/token 为首个同类镜像
secrets:
- id: sec_...
@@ -713,6 +717,7 @@ fn is_vault_sensitive_relative_path(rel: &str) -> bool {
#### P2
- 协助录入确认流、attachment cookies、自注册回写、export;按 credential/run grant(可选)。
- 浏览器侧录入主路径收敛到 **12-3 Chrome 扩展**;工作台内粘贴助手可后置或不做。
---
@@ -143,6 +143,8 @@ L-Crypto 在文件格式与迁移就绪后再做,不阻塞 agent 读路径瘦
旁路(可选,非读密阻塞路径):
mnote-web /vault UI、human CRUD、共享到 AI 本
Chrome 扩展保存到 vault(人用 human token,见 12-3**禁止**复用本设计 agent token
登录态落盘:sessions/{credId}/{accountId}.json12-3 §5.6);login/session CLI 读该路径,fallback frontmatter loginSession
vault-login-helper resolve 后外站登录 / session 回写
```
@@ -488,6 +490,7 @@ mnote-vault resolve --id <id> --field password
| 2026-07-23 | v1 初稿:本地文件 SSOT + vaultd + agent token;与 12-1 AI 策略对齐;L-Access P0 / L-Crypto P2;验收与 PR 切片 |
| 2026-07-23 | P0 落地:`mnote-vault-core` + `mnote-vault` CLIissue-token/list/get/resolve);无 web smoke 绿;UDS vaultd / web 共 core 留 P1 |
| 2026-07-23 | P1dlogin/session → core + CLI/UDSweb thin-wrapsession 回写 + login reuse 不依赖 mnote-web |
| 2026-07-24 | 交叉 12-3:登录态演进为 `sessions/{credId}/{accountId}.json`;CLI 语义不变,读序优先 session 文件 |
---
@@ -0,0 +1,929 @@
# 12-3 [process] Chrome 扩展:站点登录/注册页保存到密码箱 v1
> 创建时间:2026-07-24
> 状态:`PROCESS`
> Owner`12-vault`(扩展客户端 + mnote-web 鉴权/跨域适配;**不**改 tree kernel / Page Aggregate
> 建议 repo 落点:`design/12-vault/process/12-3-chrome-extension-vault-save-v1.md`
>
> 上位依据:
> - `design/12-vault/process/12-1-password-vault-dedicated-crud-workbench-v1.md`vault 真源、CRUD API、L0/L1、同站多账号)
> - `design/12-vault/process/12-2-vaultd-local-token-agent-read-path-v1.md`agent 读密通道;**本设计不复用 agent token 做写入口**
> - `/home/lix/.agent-infra/vault-policy.md` + `$mnote-vault`AI 读密策略;扩展默认写**人用 vault**)
> - `ARCHITECTURE.md` local-first`AGENTS.md` 密码箱薄指针
---
## 1. Overview
### 1.1 一句话
> **Chrome MV3 扩展在用户完成 MNote 远程登录后,于外站注册/登录页弹出「保存到密码箱」;默认同时保存账号密码与浏览器登录态(Cookie);登录态以「每账号独立文件」落盘;Agent 经 `$mnote-vault` 的 `login` / `session` / `resolve --field session` 复用,与 `/vault` 同一真源。**
### 1.2 痛点
| 现状 | 问题 |
|------|------|
| 录入只靠 `/vault` 手填 | 跨站复制账号密码,摩擦高、易漏改 |
| 12-1 Non-Goals 写明「不做 OS 级自动填表扩展」 | 产品一期聚焦工作台合理;**录入侧**仍缺浏览器入口 |
| 12-2 agent token 只服务读密 | **不能**把 agent capability token 交给扩展当写凭证 |
### 1.3 与 12-1 / 12-2 的关系
| 维度 | 12-1 | 12-2 | **12-3(本文)** |
|------|------|------|------------------|
| 真源 | `{workspace}/.mnote/vault/**` | 不变 | **不变** |
| 人用写入口 | `/vault` + `/api/vault/*` | 旁路 | **+ 浏览器扩展**(同 API 语义) |
| Agent 读密 / 登录态 | HTTP(演进中) | vaultd + capability token`login`/`session` 已有 | **不经扩展 resolve**;扩展只 **写入** session 文件;Agent 仍走 12-2 CLI |
| 登录态存储 | frontmatter 内嵌 `loginSession`(现状) | 同左(AI 本) | **演进为每账号独立文件**(§5.6);兼容读 frontmatter |
| 鉴权主体 | 浏览器 session cookie | agent token | **人会话 / 扩展专用 human token** |
| 自动填表 | 非目标 | — | **P0 不做**P2 可选 |
### 1.4 硬门闩(产品)
扩展在以下任一不满足时,**禁止**弹出保存、禁止自动提交 vault 写请求:
1. 已配置 **远程 MNote 基址**`baseUrl`,如 `http://127.0.0.1:3000`
2. 已用 **MNote 账号/密码** 完成登录,且会话未过期
3. 已选定可写 **workspace `rootUri`**local_folder 的 `file://…`,与工作台一致)
未登录态 UI 只允许:Options 配置 +「连接密码箱」;工具栏徽章显示「未连接」。
---
## 2. Goals / Non-Goals
### 2.1 Goals
1. **MVP 保存闭环**:外站检测到 password 表单 → 用户确认 → 写入当前用户 vault(create 或更新同站条目)。
2. **默认保存登录态**:确认保存时 **默认勾选**「同时保存登录态」;扩展用 `chrome.cookies` 抓取目标 origin 的 Cookie,写入 **每账号独立 session 文件**(§5.6)。
3. **Options 引导**:远程地址 + 账号密码登录 + 选择 workspacerootUri+ 连接状态展示。
4. **与 `/vault` 同真源**`POST /api/vault/items``PATCH /api/vault/items/{id}``PUT …/session``GET /api/vault/list`;字段对齐 `VaultCreateInput` + session file schema。
5. **安全默认**:密钥与 Cookie 只经 extension **background** 出站;content script **不**持长期 token / cookie 明文;审计无 value / cookieHeader。
6. **可选同步 AI 本**:保存后二次确认才 `share-to-ai`;勾选时 **连同 session 文件** 复制到 AI 本(否则 Agent 无法 `login` 复用)。
7. **Skill / Agent 可调用**`$mnote-vault` + vault-policy 明确 `login` / `session` / `resolve --field session` 稳态(§6.5)。
8. **可安装、可调试**Manifest V3;本地 unpacked 加载;文档化 host_permissions(含 cookies)。
### 2.2 Non-Goals(本设计不承诺 / 明确不做)
| 项 | 说明 |
|----|------|
| P0 自动填充 | 不做 Bitwarden 式 autofill;仅「保存」 |
| Agent capability token 复用 | 扩展 **禁止** 使用 `mnote-vault` agent token / vaultd UDS 写库 |
| 改 kernel / tree.* | vault 仍非图节点 |
| 云端跨机 vault 同步 | 仍 local-first;远程只是 mnote-web 可达地址 |
| 完整密码管理器对标 | 无 TOTP 引擎、无浏览器原生 Password API 劫持(P2 再评估) |
| Firefox/Safari | P0 只 Chrome/Chromium MV3API 预留可移植 |
| 在 content script 内直接 fetch vault API | 禁止(token 面扩大) |
| 默认把所有保存项 share-to-ai | 禁止静默 |
### 2.3 成功标准(设计批准后实现门闩)
- [x] Options:填 baseUrl + 登录成功 → whoami 显示用户(扩展 CONNECT + API smoke 签发 mnext1
- [x] 未登录时打开任意站 password 表单 → **无**可保存(弹层提示去连接且禁用保存)
- [ ] 登录后在示例站提交登录表单 → 弹层预填 url/username/password → 确认 → `/vault` 列表可见新条目(**Chrome 手工**
- [x] **默认勾选「同时保存登录态」** → API smoke 确认磁盘 `sessions/{credId}/{accountId}.json`
- [x] 同 origin 已有条目 → 可选「追加账号」写入 `accounts[]`**新账号自有 session 文件**(扩展 UI
- [ ] share-to-ai 勾选时 AI 本同时有 session 文件;Agent `mnote-vault login --id``mode=session` 复用(**Chrome 手工**
- [x] `$mnote-vault` skill 写明登录态调用(`login` / `session`);vault-policy 待同步
- [x] audit 有 `session_put`L0 **无** password / cookieHeader 明文(smoke 断言)
- [x] 错误 baseUrl / 401 → 明确文案;token 吊销后 401smoke
---
## 3. 问题与方案选型
### 3.1 写入通道
| 方案 | 优点 | 缺点 | 结论 |
|------|------|------|------|
| A. 扩展 → mnote-web `/api/vault/*`(人会话) | 与工作台一致;已有 create/update | 需跨域鉴权 | **采用** |
| B. 扩展 → Native Messaging → 本机写文件 | 不依赖 3000 | 无「远程地址」;安装重;与 local-first 桌面强绑定 | P2 备选「纯本机」 |
| C. 扩展 → vaultd agent token | 不启 web | token 设计给 agent 读;无完整 human CRUD 策略 | **禁止** |
### 3.2 鉴权形态
| 方案 | 说明 | 推荐阶段 |
|------|------|----------|
| **E1. Session cookie 跨域** | `POST /api/auth` 后带 `mnote_session`;扩展 fetch `credentials: 'include'` | 本机 localhost 可试;跨站 SameSite 脆弱 |
| **E2. Extension human token(推荐)** | 登录成功后服务端签发 **仅 vault.view + vault.edit** 的 Bearer;扩展存 `chrome.storage.session` | **MVP 主路径** |
| **E3. 复用 `/api/auth/mnote-web-token`** | 若现网已有可给 API 的 token | 实现时审计 scope;不足则走 E2 |
**推荐默认(钉死):E2。**
Cookie 路径(E1)可作为同机 dev 旁路,**不得**作为唯一生产假设。
E2 token 逻辑声明(与 12-2 agent token **分 aud/scope**,禁止混用):
```json
{
"v": 1,
"iss": "mnote-web",
"aud": "chrome-extension-vault",
"sub": "user:<userId>",
"scope": ["vault.view", "vault.edit"],
"rootUriAllow": ["file:///…可选绑定…"],
"iat": 0,
"exp": 0,
"jti": "uuid"
}
```
- **禁止** scope 含 `vault.resolve` / AI resolve。
- **禁止** 与 `mnv1.*` agent token 同密钥、同 aud。
- TTL 默认 **7d**(可 Options 续期 / 重新登录);revoke 按 jti。
- 传输:`Authorization: Bearer <token>`
### 3.3 远程地址与 rootUri
```text
Options 配置:
baseUrl = https://mnote.example 或 http://127.0.0.1:3000
account = 用户账号/邮箱
password = 仅登录瞬间使用;成功后**不**长期明文落盘(见 §7)
rootUri = (可选)file://… 覆盖;默认自动解析
sourceKind = local_folder(固定)
```
- `baseUrl` 规范化:去尾 `/`;仅 `http:`/`https:`;禁止 `javascript:` 等。
- **首次连接**后把 baseUrl 写入 `chrome.storage.sync`(可同步)或 `local`token 只进 **session** storage。
- **默认 rootUri(钉死)**:登录成功后 `POST /api/local-folder/workspaces/default` → 账号固定
`…/users/<actor>/workspaces/my-space``file://` rootUri(与 web 壳默认 local_folder 一致)。
Options **不必手填**;「高级」仅用于写到其它已授权 local_folder。
- 多 workspace / 自定义目录:仍可覆盖 rootUri(P1 可做下拉列表)。
---
## 4. 目标架构
### 4.1 组件图
```text
┌─────────────────────────────────────────────────────────────┐
│ 外站页面(任意 origin) │
│ content script:侦测 password 字段 / 提交;发消息给 background │
│ 不持 token;不直接打 MNote API │
└────────────────────────────┬────────────────────────────────┘
│ chrome.runtime.sendMessage
┌─────────────────────────────────────────────────────────────┐
│ Extension Service Workerbackground
│ - 持 baseUrl + human token + rootUri │
│ - 保存弹层协调 / 工具栏 badge │
│ - fetch(`${baseUrl}/api/vault/...`) │
│ - host_permissions: 用户配置的 baseUrloptional 动态) │
└────────────────────────────┬────────────────────────────────┘
│ HTTPS + Bearer
┌─────────────────────────────────────────────────────────────┐
│ mnote-web │
│ POST /api/auth → 登录 │
│ POST /api/vault/extension/token → 签发 E2(新) │
│ GET /api/auth/whoami → 校验会话/token │
│ GET /api/vault/list → 同站匹配 │
│ POST /api/vault/items → create │
│ PATCH /api/vault/items/{id} → update / 追加账号 │
│ PUT /api/vault/items/{id}/session → 写每账号 session 文件 │
│ POST /api/vault/items/{id}/share-to-ai → 可选(含 session)│
└────────────────────────────┬────────────────────────────────┘
│ 文件写
{workspace}/.mnote/vault/
entries/{credId}.md # 账号密码真源
sessions/{credId}/{acc}.json # 每账号登录态(§5.6
```
### 4.2 仓库落点(建议)
| 路径 | 职责 |
|------|------|
| `extensions/mnote-vault/`(新建) | MV3 扩展源码:manifest、background、content、options、popup |
| `rust/crates/mnote-web/src/routes/vault_extension.rs`(或并入 `vault.rs` | extension token 签发/吊销;可选 match-by-url 查询 |
| `browser/vault-workbench-runtime.js` | 可选:「复制连接信息到扩展」 |
| `design/12-vault/process/12-3-…` | 本文 |
| 文档 | `scripts/TESTING_REFERENCE.md` 增补 extension smoke |
**不**放入 `recycle/`**不**改 `core-domain` 图模型。
### 4.3 Manifest V3 最小权限
```json
{
"manifest_version": 3,
"name": "MNote Vault",
"permissions": ["storage", "activeTab", "scripting", "alarms", "cookies"],
"optional_host_permissions": ["http://*/*", "https://*/*"],
"host_permissions": [],
"background": { "service_worker": "background.js", "type": "module" },
"action": { "default_popup": "popup.html" },
"options_page": "options.html",
"content_scripts": [{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}]
}
```
说明:
- **`cookies` 权限**:默认保存登录态时,background 用 `chrome.cookies.getAll({ url })` 抓目标 origin Cookie**禁止** content script 读 cookie)。
- **optional_host_permissions**:用户在 Options 确认 baseUrl 后 `chrome.permissions.request` 只放行该 origin;目标站默认靠 content_scripts matches(仅读 DOM,不出站)。保存登录态时,对目标站 origin 也需 host 权限(`cookies` API 要求)。
- 若政策要求更紧:content_scripts 改为 `activeTab` + 用户点工具栏再注入(牺牲「自动侦测」)。**P0 推荐:全站 content script + 保存必须用户点确认**(非静默上传)。
---
## 5. 用户流程
### 5.1 首次连接(Options
```text
1. 打开扩展 Options
2. 输入 baseUrl →「测试连接」→ GET {baseUrl}/api/health 或 /api/auth/whoami(未登录可 401 但仍可达)
3. 输入 MNote 账号/密码 →「登录」
4. 扩展 background
POST {baseUrl}/api/auth { provider password, flow signIn, … }
成功后 POST {baseUrl}/api/vault/extension/token Cookie 或临时会话)
或 auth 响应内嵌 extensionToken(实现可选合并)
5. 选择/粘贴 rootUri →「保存配置」
6. Badge:已连接 · 用户 email 缩写
```
密码处理:
- Options 表单内存持有至登录成功;
- **成功后立即清除** DOM 与内存中的 password
- **禁止** `chrome.storage` 持久化 MNote 登录密码;
- 仅持久化:`baseUrl``rootUri``userId`/`email`(非敏感)、token 放 **session**(浏览器重启需重新登录或 refresh)。
### 5.2 外站保存(MVP
```text
触发(满足其一即可进入「候选」状态):
T1. 页面存在 input[type=password],且用户焦点离开该字段 / 输入长度≥阈值
T2. form submit 且 form 内含 password
T3. 用户点击扩展工具栏「保存当前页账号」
门闩:
未连接 → 仅 toast/小条:「打开 MNote Vault 扩展并登录」
已连接 → 弹出确认层(扩展 page 或 iframe 隔离 UI,见 §5.4
确认层字段:
- title(默认:document.title 截断 80 或 hostname
- url(默认:location.origin + pathname 的 login 路径;可编辑)
- username / email(从 autocomplete=username / email / name=user 启发式)
- password(来自 password input;可显示切换)
- 匹配结果:若 list 命中同站 → 「新建条目」|「追加到已有 · title」
- **「同时保存登录态」checkbox:默认勾选**(用户可取消)
- 可选 checkbox:同步到 AI 密码本(默认关;勾选时连同 session 文件)
- 按钮:保存 / 取消
确认后 background
1) create 或 update(账号密码)
2) 若「同时保存登录态」仍勾选:
chrome.cookies.getAll({ url: pageUrl 或 origin })
→ 拼 cookieHeader
→ PUT /api/vault/items/{id}/session
{ rootUri, accountId, cookieHeader, source: "chrome_extension" }
3) 若勾选 share-to-ai → POST share-to-ai(服务端复制 credential + session 文件)
4) toast 成功/失败(session 失败不回滚 credential,但提示「账号已存、登录态未写入」)
```
### 5.3 同站匹配规则(P0
服务端或客户端匹配(P0 可客户端 list 后过滤,条目量小时足够):
1. 取候选 URL 的 **registrable origin**`https://github.com`)与 path 前缀(可选)。
2. 对 list 中每条 credential 的 `url` + `urls[]`
- origin 相等 → 候选;
- 多条 → 按 `updatedAt` 降序展示前 N=5。
3. 用户选「追加到已有」:
- `PATCH` 合并 `accounts[]` 新 slot(生成 `acc_*` id);
- 顶层 `username`/`password` 是否更新:
- **默认**:仅追加 slot**不**改 primary 镜像(避免踩掉主账号);
- 勾选「设为主账号」时再写 primary。
实现可新增只读辅助(P1):
```text
GET /api/vault/match?rootUri=&url=https://example.com/login
→ { items: [ L0 投影… ] }
```
P0 无此 API 时用 `GET /api/vault/list` + 客户端过滤。
### 5.4 确认 UI 隔离
| 方式 | 说明 | 推荐 |
|------|------|------|
| Content script 注入 shadow DOM | 实现快;样式污染风险;密码进页面 JS 上下文 | MVP 可接受若确认层只读自 form |
| `chrome.action` popup 预填 | 需用户点图标;最安全 | 与 T3 共用 |
| 独立 extension 页 `chrome.windows.create` | 重 | 备选 |
**P0shadow DOM 确认层 + 工具栏手动入口双轨。**
密码值从 form 读入后立即通过 message 传 background,确认层可用 `type=password` 掩码展示;取消则丢弃。
### 5.5 注册页 vs 登录页
不强制区分 DOM:同一保存路径。
启发式:URL/path 含 `signup|register|sign-up` 时 title 默认加「注册」前缀;不改变 schema。
### 5.6 每账号独立登录态文件(核心演进)
#### 5.6.1 为何不继续只靠 frontmatter
| 现状(12-1 / 12-2 已落地) | 问题 |
|---------------------------|------|
| `loginSession` 嵌在 `entries/{credId}.md` frontmatter | **整条 credential 只有一份** session;同站多账号互相覆盖 |
| Cookie 与密码同文件 | 改 session 会 bump credential revision;审计/diff 面大 |
| 扩展默认抓 cookie 后无处按账号落盘 | 无法满足「每个账号各自登录态」 |
**钉死:登录态 = 独立文件,按 credential + account 一文件。**
frontmatter 内 `loginSession` **仅兼容读**(迁移期);新写入一律走 session 文件。
#### 5.6.2 磁盘布局
```text
{workspace}/.mnote/vault/
entries/
{credId}.md # 账号/密码/accounts[] 真源(无 cookie 明文)
sessions/
{credId}/
primary.json # 无 accounts[] 或主账号(accountId 省略 / "primary"
{accountId}.json # 对应 accounts[].id,如 acc_01H…
attachments/… # 既有附件(大 cookie 导出仍可走附件,非默认)
audit.jsonl
index.json
```
- 路径一律相对 vault root;禁止 `..`
- 文件权限:与 vault 一致(建议 `0600` 文件 / `0700` 目录)。
- **删除 credential** → 同步删除 `sessions/{credId}/` 整目录。
- **删除 account slot** → 删除对应 `{accountId}.json``primary` 若被新主账号替换则按 merge 规则重命名/覆盖。
- **share-to-ai** → 复制 `entries` 到 AI 本时 **一并复制** `sessions/{credId}/**` 到 AI vault 同相对路径;否则 Agent 无法 `login` 复用。
#### 5.6.3 Session 文件 schema
```json
{
"schema": "mnote.vault.session.v1",
"credentialId": "cred_01H…",
"accountId": "acc_01H…",
"cookieHeader": "session=…; other=…",
"cookies": [
{
"name": "session",
"value": "…",
"domain": ".example.com",
"path": "/",
"secure": true,
"httpOnly": true,
"sameSite": "lax",
"expirationDate": 1735689600
}
],
"origin": "https://example.com",
"expiresAt": "2026-07-31T00:00:00Z",
"lastLoginAt": "2026-07-24T12:00:00Z",
"source": "chrome_extension",
"updatedAt": "2026-07-24T12:00:00Z",
"revision": 1
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `schema` | 是 | 固定 `mnote.vault.session.v1` |
| `credentialId` / `accountId` | 是 | 与路径一致;`accountId` 无多账号时用 `"primary"` |
| `cookieHeader` | 推荐 | 拼好的 `Cookie` 请求头(Agent `login` 主消费) |
| `cookies[]` | 推荐(扩展写) | 结构化;便于工作台展示/过期判断;与 cookieHeader 同步 |
| `origin` | 推荐 | 抓取时的 origin |
| `expiresAt` | 推荐 | 会话逻辑过期(默认 now+7d,`MNOTE_VAULT_SESSION_TTL_HOURS` |
| `source` | 是 | `chrome_extension` \| `api` \| `human_bridge` \| `browser` \| `login_api` |
| `revision` | 是 | 乐观并发 |
**L0 投影**list/get 只暴露 `hasLoginSession` / `sessionExpiresAt` / 可选 `sessionAccountIds[]`**永不**返回 `cookieHeader` / `cookies[].value`
#### 5.6.4 Core 读路径(兼容)
`mnote-vault-core` / `login` / `put_ai_vault_session` 演进顺序:
1. **写**:始终写 `sessions/{credId}/{accountId}.json`;可选同步清掉 frontmatter `loginSession`(或保留镜像一期,二期去掉)。
2. **读(login 复用)**
a. 若指定 `--account-id` → 只读该文件;
b. 否则读 `primary.json`,否则 `accounts[0].id.json`
c. **fallback**frontmatter `loginSession`(旧数据)。
3. **新鲜度**:同现网 `login_session_is_fresh``expiresAt` + TTL)。
#### 5.6.5 扩展抓 Cookie 规则
```text
url = 确认层 url 或 tab.url 的 origin(至少 scheme+host+port
chrome.cookies.getAll({ url })
→ 过滤:
- 排除超大 value(单 cookie > 8KiB 截断并记 note
- 可选排除已知 tracking 名(P1 列表);P0 全量 origin cookies
→ cookieHeader = cookies.map(c => `${c.name}=${c.value}`).join('; ')
→ cookies[] 保留 domain/path/secure/httpOnly/sameSite/expirationDate
```
- 仅 background 持有 cookie 明文;确认层 **不**展示完整 cookie 列表(可显示「已捕获 N 条 Cookie」)。
- 用户取消「同时保存登录态」→ 跳过 PUT session。
- 登录**前**表单提交瞬间可能尚无 session cookie
- P0:仍保存当时 cookies(可能为空或仅 CSRF);toast 可提示「若刚登录,建议登录成功后再点工具栏『更新登录态』」。
- P1`webNavigation` / 登录成功 URL 启发式后再二次捕获(另 checklist)。
#### 5.6.6 与 attachments 的关系
- 12-1 附件 `kind: cookies` **保留**为大文件/导出旁路。
- **默认路径是 session 文件**,不是附件。
- 禁止把 cookieHeader 再写进 notes_markdown。
---
## 6. API 合同
### 6.1 复用(已存在)
| 方法 | 路径 | 用途 | 鉴权 |
|------|------|------|------|
| POST | `/api/auth` | 登录拿会话 | 公开 |
| GET | `/api/auth/whoami``/api/auth/session` | 校验身份 | session/token |
| GET | `/api/vault/list?rootUri=&sourceKind=local_folder` | L0 列表 / 匹配 | view |
| POST | `/api/vault/items` | 创建 | edit |
| PATCH | `/api/vault/items/{id}` | 更新 / 追加账号 | edit |
| POST | `/api/vault/items/{id}/share-to-ai` | 可选同步 AI 本(**须含 session 文件** | edit |
| POST | `/api/vault/ensure` | 确保目录 | 可写 |
| PUT | `/api/vault/ai/items/{id}/session` | AI 本 session 回写(既有;演进写文件) | AI + session scope |
**Create body 示例(扩展 → mnote-web):**
```json
{
"rootUri": "file:///home/user/Notes",
"sourceKind": "local_folder",
"title": "GitHub",
"url": "https://github.com/login",
"username": "alice",
"password": "plaintext-from-form",
"email": "alice@example.com",
"tags": ["from-extension"],
"folderPath": "imported/browser",
"notesMarkdown": "Saved via MNote Vault extension"
}
```
响应:与现网一致,L0 投影(password **masked**)。
**Update 追加账号(语义对齐 12-1 accounts[]):**
```json
{
"rootUri": "file:///…",
"accounts": [
{ "id": "acc_existing", "label": "work", "username": "…", "password": "…" },
{ "id": "acc_new", "label": "from-extension", "username": "…", "password": "…" }
]
}
```
注意:`accounts` 在现网为 **全量替换**`VaultUpdateInput.accounts: Option<Vec<…>>`)。扩展必须 **先 get 再 merge 再 patch**,禁止只发新 slot 导致清空旧账号。
### 6.2 新增(mnote-webMVP 建议)
#### `POST /api/vault/extension/token`
**前置**:已登录 sessionCookie)或刚完成 auth 同请求链。
Request
```json
{
"clientId": "chrome-extension",
"extensionId": "optional-chrome-runtime-id",
"ttlHours": 168
}
```
Response
```json
{
"ok": true,
"result": {
"token": "mnext1.…",
"expiresAt": "2026-07-31T00:00:00Z",
"scope": ["vault.view", "vault.edit"],
"userId": "…",
"email": "…"
}
}
```
#### `POST /api/vault/extension/token/revoke`
Body`{ "jti": "…" }` 或吊销当前 Bearer。
#### `PUT /api/vault/items/{id}/session`(人用 / 扩展 · 新增)
**鉴权**session cookie 或 E2 Bearer`vault.edit`)。
**写目标**:用户 vault(非 AI 本);路径 `sessions/{id}/{accountId}.json`
Request
```json
{
"rootUri": "file:///home/user/Notes",
"sourceKind": "local_folder",
"accountId": "primary",
"cookieHeader": "session=abc; other=1",
"cookies": [
{
"name": "session",
"value": "abc",
"domain": ".example.com",
"path": "/",
"secure": true,
"httpOnly": true,
"sameSite": "lax",
"expirationDate": 1735689600
}
],
"origin": "https://example.com",
"expiresAt": "2026-07-31T00:00:00Z",
"source": "chrome_extension",
"expectedRevision": null
}
```
规则:
- `cookieHeader``cookies[]` 至少一个非空;两者皆有时以 `cookies[]` 重算 header 为准(防不一致)。
- `accountId` 缺省 → `"primary"`
- 响应 L0`{ ok, result: { credentialId, accountId, hasLoginSession: true, sessionExpiresAt, revision } }`**无** cookie 明文。
- audit`action=session_put``source=chrome_extension`,无 value。
#### `GET /api/vault/items/{id}/session`(可选 P1
- 默认 **不** 返回 cookie(仅 meta)。
- 人用工作台「查看登录态元数据」用;扩展 P0 **不需要** GET。
#### share-to-ai 合同补强
`POST /api/vault/items/{id}/share-to-ai` 实现必须:
1. 复制 credential 到 AI 本(既有);
2. **复制** `sessions/{id}/**` → AI vault 同相对路径;
3. 若仅有 frontmatter `loginSession` 无文件 → 迁移写出 AI 侧 `primary.json` 再清/保留源侧策略按 core 统一。
否则 Agent 勾选同步后仍 `login` 不到 cookie。
#### 鉴权中间件扩展
`RequestContext` 识别:
1. 现有 `mnote_session` cookie
2. **新增** `Authorization: Bearer mnext1.…`extension human token
映射到同一 `actor_id`vault 路径 `require_authenticated` 通过即可。
**reveal** 是否允许 extension token
- **P0:允许**(扩展未来 autofill 需要);须审计 `source=extension`
- 若产品收紧:P0 仅 list/create/updatereveal 仍要 web session
**推荐 P0extension token 允许 list / create / update / ensure / **session put**reveal 默认关(scope 不含 vault.reveal);P2 autofill 再加 `vault.reveal`。**
E2 scope 逻辑声明可写 `["vault.view", "vault.edit"]``vault.edit` **包含** session 文件写入(不必单独 `vault.session`,避免 token 面碎片化)。
### 6.5 Agent / Skill 调用登录态(`$mnote-vault` 合同)
> **扩展只写入;Agent 只经 12-2 CLI / core 消费。** 禁止 Agent 为读 session 去扫 `.mnote/vault/sessions/**` 文件。
#### 6.5.1 稳态命令(AI 必须会)
```bash
# 0. 环境(一次)
export MNOTE_VAULT_TOKEN_FILE="${MNOTE_VAULT_TOKEN_FILE:-$HOME/.config/mnote/vault-tokens/default.token}"
# 可选:MNOTE_AI_VAULT_ACTOR / MNOTE_VAULT_WORKSPACE
# 1. 选型(未知 id
mnote-vault list
# L0 可见 hasLoginSession / sessionExpiresAt(无 cookie 明文)
# 2. 优先复用登录态(推荐主路径)
mnote-vault login --id <credId>
# 成功:mode=session | reused → 响应含 cookieHeader(仅 stdout 一次;勿贴聊天)
# 过期/无 session:走 api_first 或 human_required
# 2b. 多账号时指定账号(session 文件按 accountId
mnote-vault login --id <credId> --account-id <accId>
# 3. 人机验证后回写(chrome-bridge / 扩展已写人用库后 share-to-ai
mnote-vault session --id <credId> --cookie-header 'name=value; …' --source human_bridge
# 可选:--account-id <accId>
# 4. 只要 cookie 明文管道(实现演进;与 login 二选一)
# mnote-vault resolve --id <credId> --field session [--account-id <accId>] [--raw]
```
| 意图 | 命令 | 读哪里 |
|------|------|--------|
| 复用未过期 Cookie 访问外站 | `login` | `sessions/{id}/{account}.json` → fallback frontmatter |
| 浏览器验证后落盘 | `session` | 写同上路径 |
| 只要密码填表 | `resolve --field password` | credential md |
| 只要 Cookie 不做登录流程 | `resolve --field session`P1 落地)或 `login``cookieHeader` | session 文件 |
#### 6.5.2 Agent 行为规则(写入 skill
1. **需要已登录外站状态时**:先 `login --id`,看 `mode``session`/`reused`/`ok` 用返回的 `cookieHeader` 调目标 API / 注入浏览器。
2. **禁止** `Read`/`cat`/`grep` `.mnote/vault/sessions/**` 或 credential 内 cookie。
3. **禁止**`cookieHeader` / token 写入聊天、commit、issue。
4. `human_required` → 用 chrome-bridge 完成验证 → `session` 回写;**不要**为 session 去起 mnote-web。
5. 人用扩展写入的 session 在 **用户 vault**Agent 默认读 **AI 本** → 须用户 **share-to-ai**(或显式配置 actor 指向人用本,非默认)。
6. 多账号:list/get 看 `accounts[]` + `hasLoginSession``login --account-id` 与扩展写入的 `accountId` 对齐。
#### 6.5.3 文档落点(实现 Checklist 同步改)
| 文件 | 变更 |
|------|------|
| `/home/lix/.agent-infra/vault-policy.md` | Session 语义改为「每账号文件」;登录稳态表保留 |
| `skills/mnote-vault/SKILL.md`(全局 symlink) | 增加「登录态文件 + 调用示例」专节 |
| 12-2 | 交叉引用:session 存储演进见 12-3 §5.6CLI 语义不变 |
| 12-1 schema | 注明 `loginSession` frontmatter 兼容;新写走 `sessions/` |
#### CORS(仅 extension 需要时)
若 background 为 extension origin
-`/api/vault/*``/api/vault/extension/*``/api/auth`
- `Access-Control-Allow-Origin: chrome-extension://<id>` **或** 动态反射已登记 extensionId
- `Access-Control-Allow-Headers: Authorization, Content-Type`
- 不需要 `Allow-Credentials` 若纯 Bearer
**更干净:不靠 CORS,仅用 extension host_permissions 直连**Chrome 扩展跨域 fetch 不受页面 CORS 限制)。
**实现结论:MVP 以 host_permissions + Bearer 为主;服务端 CORS 仅在将来 Web 页面桥接时需要。**
### 6.3 错误码(扩展可分支)
| code | HTTP | 扩展动作 |
|------|------|----------|
| `vault_auth_required` | 401 | 清 token,引导 Options 登录 |
| `vault_extension_token_invalid` | 401 | 重新签发 |
| `vault_extension_token_expired` | 401 | 重新登录 |
| `vault_root_required` / `vault_root_invalid` | 400 | Options 检查 rootUri |
| `vault_permission_denied` | 403 | 提示 workspace 无写权限 |
| `vault_revision_conflict` | 409 | re-get 后重试 merge |
| `vault_mask_sentinel_rejected` | 400 | 勿提交掩码串 |
| `network_unreachable` | — | 检查 baseUrl / 本机 3000 |
### 6.4 审计
沿用 `{workspace}/.mnote/vault/audit.jsonl`
```json
{"ts":"…","action":"create","actorId":"user_x","credentialId":"cred_…","requestId":"…","ok":true,"source":"chrome_extension"}
{"ts":"…","action":"session_put","actorId":"user_x","credentialId":"cred_…","accountId":"primary","requestId":"…","ok":true,"source":"chrome_extension"}
```
`source` 字段:实现时在 append_vault_audit 增加可选 `source`(无则兼容缺省)。**禁止** value / cookieHeader。
---
## 7. 安全设计
### 7.1 威胁与缓解
| 威胁 | 缓解 |
|------|------|
| 钓鱼站诱导保存到假 baseUrl | Options 显示完整 baseUrl;首次连接确认;可选证书钉扎不做 P0 |
| 恶意页面读 extension 消息 | content 只发「候选字段」;token 永不下行 content;响应不回传 password / cookie |
| XSS 页面窃取已输入密码 | 与浏览器密码管理器同类风险;确认层不把 token 注入页面 |
| token 失窃 | session storage;短 TTLrevokescope 无 resolve |
| Cookie 被 content 窃取 | 仅 background `chrome.cookies`;不回传 content |
| 磁盘 session 文件泄露 | vault 目录权限;path deny 对 agent 通用工具;只经 CLI |
| 扩展被恶意更新 | 自用 unpacked / 签名发布策略另案;P0 本地加载 |
| 日志/崩溃转储 | background 不 console.log password / cookieHeader;错误上报脱敏 |
| 静默批量上传 | **必须用户确认**;无「自动保存所有表单」默认开 |
| AI 本扩大攻击面 | share-to-ai 默认关 + 二次确认;复制 session 须同确认 |
### 7.2 与 12-1 安全分级
| 级 | 扩展行为 |
|----|----------|
| L0 | list/match 元数据可缓存内存短时(TTL≤5min),**不**持久化到 disk 含 hint 以外 secretsession 仅 `hasLoginSession` |
| L1 | create/update 提交明文 passwordsession put 提交 cookie **仅** HTTPS 到 baseUrl;扩展本地不落盘 |
| L2 | **不提供** resolve;禁止 agent tokensession **写**允许、**读明文**不对 extension |
### 7.3 与 chrome-bridge / QA 关系
- chrome-bridge 用于 agent **人机验证** session 回写(12-2),**不是**本扩展。
- 本扩展是 **人** 的录入工具;QA 可用 Playwright 装 unpacked 扩展做 smoke,不与 Hermes QA 默认路径耦合。
---
## 8. 扩展模块地图
```text
extensions/mnote-vault/
manifest.json
background/
index.js # 消息路由、API 客户端、badge
auth.js # login / token / whoami
vault-api.js # list/create/update/session/share
cookies.js # chrome.cookies → cookieHeader + cookies[]
match.js # origin 匹配
content/
detect.js # password 表单侦测
save-prompt.js # shadow DOM 确认层(默认勾选保存登录态)
options/
options.html
options.js
popup/
popup.html # 状态 + 快捷「保存本页」+「更新登录态」+ Options
popup.js
shared/
messages.js # 消息类型常量
normalize-url.js
README.md # 安装:chrome://extensions 开发者模式
```
### 8.1 消息协议(内部)
```ts
// content → background
type Msg =
| { type: "vault/candidate"; payload: { url: string; title: string; username?: string; email?: string; password: string; pageUrl: string } }
| { type: "vault/ping" };
// background → content
type Reply =
| { type: "vault/status"; connected: boolean; email?: string }
| { type: "vault/save-result"; ok: boolean; itemId?: string; sessionSaved?: boolean; message?: string };
// popup/options → background
type Ctrl =
| { type: "vault/login"; baseUrl: string; account: string; password: string }
| { type: "vault/logout" }
| { type: "vault/set-root"; rootUri: string }
| { type: "vault/save-confirmed"; payload: SavePayload }
| { type: "vault/refresh-session"; payload: { itemId: string; accountId?: string; pageUrl: string } };
type SavePayload = {
title: string;
url: string;
username?: string;
email?: string;
password: string;
matchItemId?: string; // 追加到已有
setPrimary?: boolean;
saveSession: boolean; // 默认 true
shareToAi: boolean; // 默认 false
pageUrl: string;
};
```
### 8.2 刷新模型
- **禁止** content/background `setInterval` 轮询 vault list。
- 匹配:保存前 **单次** list/match;结果可 memory cache 至多 5 分钟或 rootUri 变更时失效。
- 连接状态:登录/登出/401 时更新 badge;可用 `chrome.alarms` 做 token 到期前提醒(非轮询 vault)。
---
## 9. 分期
| 阶段 | 范围 | 依赖 |
|------|------|------|
| **P0 MVP** | Options 连接;Bearer;保存确认层;create**默认保存登录态 → session 文件**list 匹配;toolbar 手动保存;skill/policy 更新 | mnote-web token + session PUTcore 写 `sessions/` |
| **P1** | get+merge `accounts[]` + 每账号 session`login --account-id``/vault/match`share-to-ai 复制 sessionworkbench「复制到扩展」;登录成功后二次抓 cookie | P0 |
| **P2** | 自动填充(`vault.reveal`);`resolve --field session`;提交成功智能检测;Firefox | 安全评审 |
| **P3** | Native Messaging 纯本机旁路;导入浏览器 CSVTOTP;去掉 frontmatter `loginSession` 镜像 | 另设计 |
### 9.1 与 12-1 P2「协助录入」关系
12-1 §6 P2「协助录入确认流」可与本扩展合并叙事:**浏览器侧录入 = 12-3;工作台内粘贴助手可后置或不做。**
---
## 10. 实现 Checklist(批准设计后执行)
### PR-A — 设计与开关
- [x] 本文合入 `design/12-vault/process/`
- [x] 12-1 / 12-2 交叉引用 session 文件演进(§5.6
- [ ] Feature flag(可选):`MNOTE_VAULT_EXTENSION=1` 控制 token 路由是否注册(P0 随 `MNOTE_VAULT=1`
### PR-B — mnote-web + coresession 文件)
- [x] `POST /api/vault/extension/token` + revoke
- [x] Bearer `mnext1` 解析接入 `RequestContext` / vault 鉴权
- [x] scopeview+edit(含 session put);**无** resolve
- [x] `PUT /api/vault/items/{id}/session` → 写 `sessions/{id}/{accountId}.json`
- [x] core`put_login_session` / `login` 读路径优先 session 文件,fallback frontmatter
- [x] share-to-ai **复制** session 目录
- [x] audit `source=chrome_extension``session_put` 无 cookie 明文
- [x] 单测:签发、revoke、session 文件读写(`vault_extension_token` + `session_file_put_resolve_and_share_copy`
- [x] API smoke`scripts/vault-extension-api-smoke.js`auth → mnext1 → create → PUT session → disk → revoke
- [ ] P1`GET /api/vault/match``login --account-id`
### PR-C — 扩展 MVP
- [x] `extensions/mnote-vault` MV3 脚手架(含 `cookies` 权限)
- [x] OptionsbaseUrl / 登录 / rootUri
- [x] background vault-api + cookies 抓取
- [x] content 侦测 + 确认层(**默认勾选保存登录态**)
- [x] popup:状态 + 保存本页 + 更新登录态
- [x] README 安装步骤
### PR-D — Skill / Policy
- [x] 更新 `/home/lix/.agent-infra/vault-policy.md`(每账号 session 文件 + 调用)
- [x] 更新 `skills/mnote-vault/SKILL.md`login/session 与文件语义)
- [x] 确认全局 symlink`.grok` / Codex)→ 仓库 `skills/mnote-vault`
### PR-E — 验收
- [x] API 层:`node scripts/vault-extension-api-smoke.js` 对 3000 通过
- [ ] 本机:unpacked 扩展 Chrome 手工加载(用户验收)
- [ ] 测试账号登录扩展 → 保存 example → `/vault` 可见条目 + `sessions/…json`
- [ ] share-to-ai 后 `mnote-vault login --id``mode=session`
- [x] 未登录 content 禁用保存(仅提示去 Options)
- [ ] `TESTING_REFERENCE.md` 段落
### PR-F — P1 增强(可另 PR
- [x] 同站 accounts 合并 + 每账号 session(扩展 MATCH_URL + append mode
- [ ] workbench 复制连接信息
- [ ] 登录成功后二次抓 cookie
- [ ] `resolve --field session`
---
## 11. 验收场景(手工 / smoke
| # | 场景 | 期望 |
|---|------|------|
| S1 | 无配置打开 example.com 登录表 | 无保存弹层或仅「去连接」 |
| S2 | 配置错误 baseUrl | 测试连接失败,不存 token |
| S3 | 正确登录 + rootUri | badge 已连接 |
| S4 | 填用户名密码 → 点保存 → 确认(默认保存登录态) | create 成功;`sessions/{id}/primary.json` 存在;password reveal 正确 |
| S5 | 取消「同时保存登录态」 | 仅 credential,无新 session 文件 |
| S6 | 同站再存另一账号选「追加」 | accounts 两个;**各有** session 文件(若均勾选) |
| S7 | token 过期后保存 | 401 → 引导重新登录,不写半截 |
| S8 | 勾选同步 AI | AI 本有 credential **+** session`mnote-vault login` 可复用 |
| S9 | 不勾选同步 AI | 仅人用库有 sessionAI `login` 无该 id 或无 cookie |
| S10 | 审计文件 | 有 create / session_put,无明文密码/cookie 行 |
| S11 | skill 文档 | Agent 按 skill 用 `login`/`session`,不扫 vault 文件 |
---
## 12. Alternatives Considered
| 方案 | 结论 |
|------|------|
| 只做 bookmarklet 调 API | 无可靠 backgroundtoken 易进页面;否 |
| 浏览器原生 Password Manager 导出再 import | 不解决「当下保存」;可作迁移旁路 |
| 全部走 Native Host 写磁盘 | 无远程 baseUrl;与用户需求不符;P2 可选 |
| 扩展直接读 `.mnote/vault` 文件 | 违反 vault path deny 与加密演进;禁止 |
| 复用 agent `mnote-vault` token | 权限与 actor 错误;禁止 |
| 继续只嵌 frontmatter `loginSession` | 同站多账号互踩;否;演进为每账号文件 |
| session 只做 attachments 附件 | 路径长、list 难;默认 session 文件,附件作导出旁路 |
---
## 13. 待决问题(实现前钉死;推荐已标)
| # | 问题 | 推荐默认 |
|---|------|----------|
| D1 | 鉴权 E1 cookie vs E2 Bearer | **E2 Bearer**dev 可附带 cookie |
| D2 | content_scripts 全站 vs activeTab | **全站侦测 + 用户确认保存** |
| D3 | extension token 是否 reveal | **P0 否**P2 autofill 再开 |
| D4 | rootUri 如何获得 | **P0 手动 / 工作台复制**P1 列 directory grant |
| D5 | 默认 folderPath | `imported/browser` |
| D6 | 是否默认 tags | `["from-extension"]` |
| D7 | 扩展目录 monorepo 还是独立 repo | **monorepo `extensions/mnote-vault`** |
| D8 | 发布渠道 | P0 仅 unpacked / 自签;商店上架另案 |
| D9 | 与 12-1「不做自动填表」文案 | 修订交叉引用,避免矛盾 |
| D10 | 密码字段启发式失败 | 工具栏手动选中 / 用户可编辑确认层 |
| D11 | 默认是否保存登录态 | **是(默认勾选)**;用户可取消 |
| D12 | session 存 frontmatter vs 独立文件 | **独立文件** `sessions/{cred}/{acc}.json`frontmatter 只兼容读 |
| D13 | 无多账号时 accountId | **`primary`** |
| D14 | 登录瞬间 cookie 可能不全 | P0 仍写 + 工具栏「更新登录态」;P1 登录成功二次抓 |
---
## 14. 修订记录
| 日期 | 变更 |
|------|------|
| 2026-07-24 | 初稿:Chrome MV3 保存扩展;E2 human token;复用 vault CRUD;与 12-1/12-2 边界钉死 |
| 2026-07-24 | 增补:默认保存登录态;每账号独立 session 文件;PUT session APIskill/agent 调用合同 §6.5 |
---
## 15. 批准后下一步(给实现者)
1. 产品确认 §13 D1D5、**D11–D14**(默认可直接开工)。
2.**PR-B**token + session 文件 + 鉴权)再 **PR-C**(扩展),避免扩展对接空 API。
3. 同步 **PR-D** skill/policy,保证 Agent 知道 `login`/`session` 读的是 session 文件。
4. 不做 autofill 直到 P0 保存闭环(账号 + 登录态)绿 + 安全过目。
5. 扩展是 **人用录入**Agent 读密/读登录态 **仍只走** `$mnote-vault` CLI**不是**扩展通道。
+96
View File
@@ -0,0 +1,96 @@
# MNote Vault · Chrome 扩展(MV3
将外站登录/注册账号与浏览器 Cookie 保存到 MNote 密码箱。
设计稿:`design/12-vault/process/12-3-chrome-extension-vault-save-v1.md`
## 安装(unpacked
1. 启动 mnote-web(默认 `http://127.0.0.1:3000`)。
2. Chrome → `chrome://extensions` → 开启「开发者模式」→「加载已解压的扩展程序」。
3. 选择本目录:`extensions/mnote-vault`
4. 打开扩展 **Options**
- baseUrl:本机 `http://127.0.0.1:3000`,或外网 frp 完整地址(如 `https://www.xxx.nyat.app:44938`,含端口)
- MNote 账号/密码 → **连接并登录**
- **首次外网连接会弹「允许访问该网站」**,必须点允许;否则 Chrome 报 `Failed to fetch`(不是隧道挂了)
- **rootUri 一般不用填**:登录后自动取该账号默认工作区
`POST /api/local-folder/workspaces/default``…/users/<actor>/workspaces/my-space`)。
仅当要把密码箱写到别的 local_folder 时,在「高级」里覆盖。
5. 在登录页 **提交** 表单;跳转成功后页面右下角弹出确认层(可显示/隐藏密码核对)。
也可点工具栏「保存当前页账号」(优先使用 pending draft)。
6. **登录态(Cookie)依赖网站访问权限**v0.2.3 起连接 Options / 点保存时会申请 `http://*/*` + `https://*/*`
Chrome 提示「读取和更改所有网站的数据」时必须点允许,否则只能保存账号密码、无法写入 `sessions/`
若仍提示「未捕获到 Cookie」:`chrome://extensions` → MNote Vault → 网站访问权限 → **所有网站**
7. **去重(v0.2.3**:同一站点 + 同一用户名 + 同一密码已保存后,再次登录**不再弹确认层**(仅必要时静默补写 Cookie)。
点「取消」会暂时不再询问;勾选「此网站不再询问」后该 origin 永久跳过自动提示(仍可用工具栏手动保存)。
## 保存流程(P0P1
对齐 KeePassXC / Bitwarden 思路,**不使用 `webRequest`**
```text
content: form submit / 提交按钮
→ PENDING_SAVEbackground 内存 + chrome.storage.session
→ SPA 约 1.5s 仍在同 URL:直接弹确认层
→ 整页跳转:tabs.onUpdated status=complete
同站 hostname 匹配 → OPEN_SAVE_UI_WITH_DRAFT
用户确认
→ SAVE_CREDENTIAL(此时再 cookies.getAll
→ P1:约 2.5s 后二次 PUT sessionsetTimeout + alarms 备份)
```
| 项 | 说明 |
|----|------|
| 主触发 | **submit / 提交按钮**,不是 password blur |
| 凭证跨导航 | background `pendingByTab`TTL 约 3 分钟 |
| 弹窗时机 | **登录后页面**(或 SPA 同页 fallback |
| Cookie | 确认保存时抓取;空则提示 + 自动二次捕获 |
| 密码核对 | 确认层密码框可「显示/隐藏」 |
| 鉴权 | `POST /api/auth``POST /api/vault/extension/token` → Bearer `mnext1.*` |
| 写账号 | `POST /api/vault/items``PATCH` 追加账号 |
| 写登录态 | `PUT /api/vault/items/{id}/session``sessions/{id}/{accountId}.json` |
### 消息类型(background
| type | 作用 |
|------|------|
| `PENDING_SAVE` | content 提交时缓存 draft |
| `GET_PENDING` / `CLEAR_PENDING` / `MARK_PENDING_PROMPTED` | 查询 / 取消 / 防重复弹窗 |
| `PROMPT_PENDING` | 工具栏打开确认层(优先 pending) |
| `OPEN_SAVE_UI` / `OPEN_SAVE_UI_WITH_DRAFT` | background → content 展示 UI |
| `SAVE_CREDENTIAL` | 真正写 vault + session |
| `LIST_FOLDERS` | 拉取已有 `folderPath` 列表 + 上次选用分组(确认层下拉) |
| `UPDATE_SESSION` | 工具栏手动更新 Cookie |
### 分组(folderPath
确认层提供:
- 下拉:已有分组(来自 vault list 的 `folderPath`,含父路径前缀)+「(无分组)」+「+ 新建分组…」
- 新建:自由填写名称,支持 `/` 分层(如 `工作/客户`
- 默认选中:上次保存时选用的分组(`chrome.storage.local.lastFolderPath`
- **不再**硬编码 `imported/browser`
## 自测建议
1. 普通表单登录(整页跳转)→ 登录后应出现确认层,密码可显示核对。
2. SPA 登录(URL 不变)→ 约 1.5s 后同页弹层。
3. 工具栏「保存当前页」:若刚提交过,应预填 pending 密码。
4. 取消确认层 → pending 清除,不再重复弹。
5. 保存后 cookie 为空时:提示二次捕获;稍后可用「更新登录态」。
```bash
# 后端 token + session 单测
cd rust && cargo test -p mnote-web --lib routes::vault_extension_token::tests
cd rust && cargo test -p mnote-web --lib routes::vault_store::tests::session_file_put_resolve_and_share_copy
# API smoke(需 3000 已起 + 测试账号)
node scripts/vault-extension-api-smoke.js
```
## 权限
- `storage` / `cookies` / `activeTab` / `scripting` / `tabs` / `alarms`
- **无** `webRequest` / `webNavigation`P0P1 用 `tabs.onUpdated` 足够)
- `optional_host_permissions`Options 点击时申请 baseUrl;保存 Cookie 时申请目标站 origin
- 外网带非标端口时 origin 含端口,例如 `https://www.xxx.nyat.app:44938/*`
File diff suppressed because it is too large Load Diff
+655
View File
@@ -0,0 +1,655 @@
/**
* Content script: detect password form submit, confirm UI, message background.
* Does NOT hold tokens or cookie secrets.
*
* KeePassXC-style flow:
* - submit / submit-button click → PENDING_SAVE (background)
* - after navigation, background opens OPEN_SAVE_UI_WITH_DRAFT
* - SPA same-page: background spa fallback also opens draft UI
* - blur is NOT a primary save trigger
*/
(() => {
if (window.__mnoteVaultContentInstalled) return;
window.__mnoteVaultContentInstalled = true;
const HOST_ID = "mnote-vault-save-host";
/** Last known password on this document only (lost on full navigation). */
let lastPassword = "";
let lastUsername = "";
let lastEmail = "";
/** Prevent double PENDING_SAVE from submit + button click. */
let submitLockUntil = 0;
const SUBMIT_LOCK_MS = 1200;
function findUsernameNear(passwordInput) {
const form =
passwordInput?.form || passwordInput?.closest?.("form") || document;
const candidates = form.querySelectorAll(
'input[type="email"], input[type="text"], input[name*="user" i], input[name*="login" i], input[name*="email" i], input[autocomplete="username"], input[autocomplete="email"]'
);
for (const el of candidates) {
if (el === passwordInput) continue;
if (el.type === "password" || el.type === "hidden") continue;
const v = (el.value || "").trim();
if (v) return { username: v, email: el.type === "email" ? v : "" };
}
// Fallback: any non-empty text-like input in form
const more = form.querySelectorAll(
"input:not([type]), input[type=text], input[type=email], input[type=tel]"
);
for (const el of more) {
if (el.type === "password" || el.type === "hidden") continue;
const v = (el.value || "").trim();
if (v) return { username: v, email: el.type === "email" ? v : "" };
}
return { username: "", email: "" };
}
function findPasswordIn(root) {
const scope = root || document;
const list = scope.querySelectorAll?.('input[type="password"]') || [];
for (const el of list) {
if (el.value && el.value.length >= 1) return el;
}
return list[0] || null;
}
function isRegisterPath() {
const p = location.pathname.toLowerCase();
return /sign[-_]?up|register|signup|join|create[-_]?account/.test(p);
}
function buildTitle() {
let title = (document.title || "").slice(0, 80);
if (isRegisterPath() && title && !/注册|register|sign.?up/i.test(title)) {
title = `注册 · ${title}`;
}
return title || location.hostname;
}
function collectDraftFromDom(preferPasswordInput) {
const pwEl =
preferPasswordInput ||
(document.activeElement?.type === "password"
? document.activeElement
: null) ||
findPasswordIn(document);
const pw = (pwEl && pwEl.value) || lastPassword || "";
const near = pwEl
? findUsernameNear(pwEl)
: { username: lastUsername, email: lastEmail };
const username = (near.username || lastUsername || "").trim();
const email = (near.email || lastEmail || "").trim();
if (pw) lastPassword = pw;
if (username) lastUsername = username;
if (email) lastEmail = email;
return {
pageUrl: location.href,
title: buildTitle(),
username,
email,
password: pw,
};
}
function removeHost() {
const el = document.getElementById(HOST_ID);
if (el) el.remove();
}
function sendPending(draft) {
if (!draft?.password) return;
const now = Date.now();
if (now < submitLockUntil) return;
submitLockUntil = now + SUBMIT_LOCK_MS;
lastPassword = draft.password;
if (draft.username) lastUsername = draft.username;
if (draft.email) lastEmail = draft.email;
chrome.runtime.sendMessage(
{
type: "PENDING_SAVE",
pageUrl: draft.pageUrl || location.href,
title: draft.title || buildTitle(),
username: draft.username || "",
password: draft.password,
email: draft.email || "",
fromSubmit: true,
},
() => {
// ignore response; SW may be waking
void chrome.runtime.lastError;
}
);
}
/**
* @param {object} draft
* @param {{ fromPending?: boolean }} [opts]
*/
async function showConfirm(draft, opts = {}) {
removeHost();
const host = document.createElement("div");
host.id = HOST_ID;
host.style.cssText =
"all:initial;position:fixed;z-index:2147483646;right:16px;bottom:16px;font-family:system-ui,sans-serif;";
const shadow = host.attachShadow({ mode: "closed" });
const style = document.createElement("style");
style.textContent = `
.card {
width: 320px; max-width: 92vw;
background: #1a1d23; color: #e8eaed;
border: 1px solid #3a3f4b; border-radius: 12px;
box-shadow: 0 12px 40px rgba(0,0,0,.45);
padding: 14px 14px 12px; font-size: 13px; line-height: 1.4;
}
h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
label { display:block; margin: 8px 0 2px; color:#9aa0a6; font-size:11px; }
input[type=text], input[type=password] {
width: 100%; box-sizing: border-box;
background:#0f1115; color:#e8eaed; border:1px solid #3a3f4b;
border-radius:6px; padding:6px 8px; font-size:13px;
}
.pw-wrap {
display:flex; align-items:stretch; gap:0;
border:1px solid #3a3f4b; border-radius:6px; overflow:hidden;
background:#0f1115;
}
.pw-wrap input {
flex:1; min-width:0; border:0; border-radius:0; padding:6px 8px;
}
.pw-wrap input:focus { outline:none; }
.btn-pw-toggle {
flex:0 0 auto; min-width:52px; margin:0; border:0; border-left:1px solid #3a3f4b;
border-radius:0; background:#2a2f3a; color:#e8eaed; padding:0 10px;
font-size:11px; cursor:pointer; line-height:1;
}
.btn-pw-toggle:hover { background:#343b49; }
.btn-pw-toggle:focus-visible { outline:2px solid #3b82f6; outline-offset:-2px; }
.row { display:flex; gap:8px; align-items:center; margin-top:8px; font-size:12px; }
.row input { width:auto; }
.actions { display:flex; gap:8px; justify-content:flex-end; margin-top:12px; }
button {
border:0; border-radius:8px; padding:7px 12px; cursor:pointer; font-size:12px;
}
.btn-cancel { background:#2a2f3a; color:#e8eaed; }
.btn-save { background:#3b82f6; color:#fff; font-weight:600; }
.btn-save:disabled { opacity:.5; cursor:wait; }
.msg { margin-top:8px; font-size:11px; color:#9aa0a6; min-height:14px; }
.msg.err { color:#f87171; }
.msg.ok { color:#4ade80; }
select {
width:100%; box-sizing:border-box; background:#0f1115; color:#e8eaed;
border:1px solid #3a3f4b; border-radius:6px; padding:6px 8px;
}
.muted { color:#9aa0a6; font-size:11px; margin-top:4px; }
.banner {
margin: 0 0 8px; padding: 6px 8px; border-radius: 6px;
background: #243044; color: #93c5fd; font-size: 11px;
}
.folder-new {
display:none; margin-top:6px;
}
.folder-new.is-open { display:block; }
.folder-hint { margin-top:4px; }
`;
const fromPending = Boolean(draft.fromPending || opts.fromPending);
const card = document.createElement("div");
card.className = "card";
card.innerHTML = `
<h3>保存到 MNote 密码箱</h3>
${
fromPending
? `<div class="banner">已捕获登录提交${draft.pendingReason === "nav_complete" ? "(登录后页面)" : ""} · 请确认后保存</div>`
: ""
}
<label>标题</label>
<input type="text" id="title" />
<label>URL</label>
<input type="text" id="url" />
<label>用户名</label>
<input type="text" id="username" autocomplete="off" />
<label>密码</label>
<div class="pw-wrap">
<input type="password" id="password" autocomplete="off" />
<button type="button" class="btn-pw-toggle" id="togglePw" aria-label="显示密码" title="显示/隐藏密码">显示</button>
</div>
<label>分组</label>
<select id="folder">
<option value="">(无分组)</option>
<option value="__new__"> 新建分组…</option>
</select>
<div class="folder-new" id="folderNewWrap">
<label>新分组名(可用 / 分层,如 工作/客户)</label>
<input type="text" id="folderNew" placeholder="例如:个人 / 工作 / 客户A" autocomplete="off" />
</div>
<div class="muted folder-hint" id="folderHint">读取已有分组…</div>
<label>匹配已有条目</label>
<select id="match">
<option value="create">新建条目</option>
</select>
<div class="row">
<input type="checkbox" id="saveSession" checked />
<label for="saveSession" style="margin:0;color:#e8eaed">同时保存登录态(Cookie</label>
</div>
<div class="row">
<input type="checkbox" id="shareAi" />
<label for="shareAi" style="margin:0;color:#e8eaed">同步到 AI 密码本</label>
</div>
<div class="row">
<input type="checkbox" id="neverAsk" />
<label for="neverAsk" style="margin:0;color:#e8eaed">此网站不再询问</label>
</div>
<div class="muted" id="cookieHint"></div>
<div class="msg" id="msg"></div>
<div class="actions">
<button type="button" class="btn-cancel" id="cancel">取消</button>
<button type="button" class="btn-save" id="save">保存</button>
</div>
`;
shadow.appendChild(style);
shadow.appendChild(card);
document.documentElement.appendChild(host);
const $ = (id) => shadow.getElementById(id);
$("title").value = draft.title || buildTitle();
$("url").value = draft.pageUrl || location.href;
$("username").value = draft.username || lastUsername || "";
$("password").value = draft.password || lastPassword || "";
function syncFolderNewVisibility() {
const isNew = $("folder").value === "__new__";
$("folderNewWrap").classList.toggle("is-open", isNew);
if (isNew) {
try {
$("folderNew").focus();
} catch {
/* ignore */
}
}
}
function resolveFolderPath() {
const sel = $("folder").value;
if (sel === "__new__") {
return String($("folderNew").value || "").trim();
}
return String(sel || "").trim();
}
$("folder").onchange = syncFolderNewVisibility;
$("togglePw").onclick = () => {
const input = $("password");
const btn = $("togglePw");
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.textContent = show ? "隐藏" : "显示";
btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码");
};
// status + match
chrome.runtime.sendMessage({ type: "GET_STATUS" }, (st) => {
void chrome.runtime.lastError;
const r = st?.result || st;
if (!st?.ok || !r?.connected) {
$("msg").className = "msg err";
$("msg").textContent = "未连接:请打开扩展 Options 登录 MNote";
$("save").disabled = true;
}
});
const matchUrl = $("url").value.trim() || location.href;
chrome.runtime.sendMessage(
{ type: "MATCH_URL", pageUrl: matchUrl },
(res) => {
void chrome.runtime.lastError;
const items = res?.result?.items || [];
const sel = $("match");
for (const it of items) {
const opt = document.createElement("option");
opt.value = it.id;
opt.textContent = `追加到 · ${it.title || it.id}`;
sel.appendChild(opt);
}
}
);
// 已有分组 + 上次选用
chrome.runtime.sendMessage({ type: "LIST_FOLDERS" }, (res) => {
void chrome.runtime.lastError;
const r = res?.result || res || {};
const folders = Array.isArray(r.folders) ? r.folders : [];
const last = String(r.lastFolderPath || "").trim();
const sel = $("folder");
// 保留「无分组」「新建」两端选项,中间插入已有路径
const newOpt = sel.querySelector('option[value="__new__"]');
for (const fp of folders) {
const opt = document.createElement("option");
opt.value = fp;
opt.textContent = fp;
sel.insertBefore(opt, newOpt);
}
if (last) {
// 上次路径若不在列表中,也加一条再选中
let found = false;
for (const o of sel.options) {
if (o.value === last) {
found = true;
break;
}
}
if (!found) {
const opt = document.createElement("option");
opt.value = last;
opt.textContent = last;
sel.insertBefore(opt, newOpt);
}
sel.value = last;
} else {
sel.value = "";
}
syncFolderNewVisibility();
$("folderHint").textContent = folders.length
? `${folders.length} 个已有分组;可选已有或新建`
: "暂无已有分组,可选择「新建分组」并填写名称";
});
// Request broad host access so cookies.getAll can see session cookies.
// Best-effort here; the critical grant happens on Save click (user gesture).
chrome.runtime.sendMessage(
{ type: "ENSURE_PAGE_HOST", pageUrl: matchUrl, broad: true },
() => {
void chrome.runtime.lastError;
chrome.runtime.sendMessage(
{ type: "CAPTURE_COOKIE_COUNT", pageUrl: matchUrl },
(res) => {
void chrome.runtime.lastError;
const n = res?.result?.count ?? 0;
const err = res?.result?.error;
if (n > 0) {
$("cookieHint").textContent = `将捕获约 ${n} 条 Cookie`;
} else if (err) {
$("cookieHint").textContent =
`无法读取 Cookie${err}。点「保存」时会再次申请网站权限;也可在扩展详情设为「所有网站」。`;
} else {
$("cookieHint").textContent =
"当前可能尚无会话 Cookie。点「保存」时会申请 Cookie 权限并捕获;若仍为 0,请检查扩展网站访问权限。";
}
}
);
}
);
$("cancel").onclick = () => {
const forever = Boolean($("neverAsk")?.checked);
chrome.runtime.sendMessage(
{
type: "DISMISS_SAVE",
pageUrl: $("url").value.trim() || location.href,
username: $("username").value.trim(),
forever,
},
() => {
void chrome.runtime.lastError;
removeHost();
}
);
};
$("save").onclick = async () => {
$("save").disabled = true;
$("msg").className = "msg";
$("msg").textContent = "保存中…";
const matchVal = $("match").value;
const folderPath = resolveFolderPath();
if ($("folder").value === "__new__" && !folderPath) {
$("msg").className = "msg err";
$("msg").textContent = "请填写新分组名称";
$("save").disabled = false;
return;
}
// Critical: request cookie host permission in this click gesture.
// Without it, chrome.cookies.getAll returns [] and login session cannot be saved.
let hostPermissionGranted = false;
try {
hostPermissionGranted = await new Promise((resolve) => {
chrome.runtime.sendMessage(
{
type: "ENSURE_PAGE_HOST",
pageUrl: $("url").value.trim() || location.href,
broad: true,
},
(res) => {
void chrome.runtime.lastError;
resolve(Boolean(res?.ok || res?.result?.ok || res?.result?.hasHost));
}
);
});
} catch {
hostPermissionGranted = false;
}
const payload = {
pageUrl: $("url").value.trim() || location.href,
title: $("title").value.trim(),
username: $("username").value.trim(),
password: $("password").value,
email: draft.email || lastEmail || "",
folderPath,
saveSession: $("saveSession").checked,
shareToAi: $("shareAi").checked,
mode: matchVal === "create" ? "create" : "append",
existingId: matchVal === "create" ? null : matchVal,
recaptureSession: true,
hostPermissionGranted,
};
chrome.runtime.sendMessage(
{ type: "SAVE_CREDENTIAL", payload },
(res) => {
void chrome.runtime.lastError;
if (!res?.ok) {
$("msg").className = "msg err";
$("msg").textContent = res?.error || "保存失败";
$("save").disabled = false;
return;
}
const r = res.result || {};
let text = `已保存 ${r.credentialId || ""}`;
if (folderPath && matchVal === "create") text += ` · 分组 ${folderPath}`;
if (r.sessionError) text += ` · 登录态: ${r.sessionError}`;
else if (payload.saveSession && r.sessionSaved) text += " · 含登录态";
else if (payload.saveSession) text += " · 登录态待二次捕获";
if (r.sessionRecaptureScheduled && !r.sessionSaved) {
text += " · 将二次捕获 Cookie";
}
$("msg").className = "msg ok";
$("msg").textContent = text;
setTimeout(removeHost, r.sessionError ? 3200 : 1800);
}
);
};
}
/**
* Manual toolbar open: prefer pending draft, else live form / last known.
*/
function openFromManual() {
chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => {
void chrome.runtime.lastError;
const pending = res?.result?.pending;
if (pending?.password) {
showConfirm(
{
pageUrl: pending.pageUrl || location.href,
title: pending.title || buildTitle(),
username: pending.username || "",
email: pending.email || "",
password: pending.password,
fromPending: true,
pendingReason: "manual",
},
{ fromPending: true }
);
return;
}
const draft = collectDraftFromDom();
showConfirm(draft);
});
}
function onSubmit(e) {
const form = e.target;
if (!(form instanceof HTMLFormElement)) return;
const pw = findPasswordIn(form) || findPasswordIn(document);
if (!pw || !pw.value) return;
const draft = collectDraftFromDom(pw);
if (!draft.password) return;
// Do not block navigation; stash in background immediately.
sendPending(draft);
}
/**
* Capture click on submit-like controls (including formless login UIs).
*/
function onPointerDownCapture(e) {
const t = e.target;
if (!(t instanceof Element)) return;
const btn = t.closest(
'button[type="submit"], input[type="submit"], button:not([type]), [type="submit"], [role="button"]'
);
if (!btn) return;
// Avoid random buttons far from any password field.
const form = btn.closest("form");
const pw =
(form && findPasswordIn(form)) ||
findPasswordIn(btn.closest("div,section,main,body") || document);
if (!pw || !pw.value || pw.value.length < 1) return;
// Heuristic: prefer buttons that look like login/submit.
const label = (
btn.getAttribute("aria-label") ||
btn.value ||
btn.textContent ||
""
)
.trim()
.toLowerCase();
const looksSubmit =
btn.matches('button[type="submit"], input[type="submit"], [type="submit"]') ||
/log\s*in|sign\s*in|sign\s*up|register|提交|登录|注册|continue|next|进入/.test(
label
) ||
Boolean(form);
if (!looksSubmit) return;
const draft = collectDraftFromDom(pw);
if (draft.password) sendPending(draft);
}
// Remember password as user types (still only in this document).
function onPasswordInput(e) {
const t = e.target;
if (!(t instanceof HTMLInputElement) || t.type !== "password") return;
if (t.value) lastPassword = t.value;
const near = findUsernameNear(t);
if (near.username) lastUsername = near.username;
if (near.email) lastEmail = near.email;
}
document.addEventListener("submit", onSubmit, true);
document.addEventListener("pointerdown", onPointerDownCapture, true);
document.addEventListener("input", onPasswordInput, true);
chrome.runtime.onMessage.addListener((msg, _s, sendResponse) => {
if (msg?.type === "OPEN_SAVE_UI_WITH_DRAFT") {
const d = msg.draft || {};
showConfirm(
{
pageUrl: d.pageUrl || location.href,
title: d.title || buildTitle(),
username: d.username || lastUsername || "",
email: d.email || lastEmail || "",
password: d.password || lastPassword || "",
fromPending: true,
pendingReason: d.pendingReason || "pending",
},
{ fromPending: true }
);
sendResponse({ ok: true });
return false;
}
if (msg?.type === "OPEN_SAVE_UI") {
openFromManual();
sendResponse({ ok: true });
return false;
}
return false;
});
// After full navigation inject: if background still has pending for this tab
// and did not manage to message us yet, pull once (belt-and-suspenders).
function pullPendingOnLoad() {
// Let tabs.onUpdated / OPEN_SAVE_UI_WITH_DRAFT win the race first.
setTimeout(() => {
if (document.getElementById(HOST_ID)) return;
chrome.runtime.sendMessage({ type: "GET_PENDING" }, (res) => {
void chrome.runtime.lastError;
const p = res?.result?.pending;
if (!p?.password || p.prompted) return;
if (document.getElementById(HOST_ID)) return;
// Dedupe before content-side pull opens UI (same site already saved).
chrome.runtime.sendMessage(
{
type: "CHECK_DEDUPE",
pageUrl: p.pageUrl || location.href,
username: p.username || "",
password: p.password || "",
},
(dedupeRes) => {
void chrome.runtime.lastError;
const d = dedupeRes?.result || dedupeRes;
if (d?.suppress) {
chrome.runtime.sendMessage({ type: "CLEAR_PENDING" }, () => {
void chrome.runtime.lastError;
});
return;
}
if (document.getElementById(HOST_ID)) return;
showConfirm(
{
pageUrl: p.pageUrl || location.href,
title: p.title || buildTitle(),
username: p.username || "",
email: p.email || "",
password: p.password,
fromPending: true,
pendingReason: "content_pull",
},
{ fromPending: true }
);
chrome.runtime.sendMessage(
{
type: "MARK_PENDING_PROMPTED",
promptedUrl: location.href,
},
() => {
void chrome.runtime.lastError;
}
);
}
);
});
}, 700);
}
if (document.readyState === "complete") {
pullPendingOnLoad();
} else {
window.addEventListener("load", pullPendingOnLoad, { once: true });
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

+416
View File
@@ -0,0 +1,416 @@
/**
* mnote-web vault API client (background only).
* Token / secrets never enter content scripts.
*/
/**
* @typedef {object} VaultConfig
* @property {string} baseUrl
* @property {string} rootUri
* @property {string} [token]
* @property {string} [userId]
* @property {string} [email]
*/
/**
* @param {string} baseUrl
* @returns {string}
*/
export function normalizeBaseUrl(baseUrl) {
const raw = String(baseUrl || "").trim().replace(/\/+$/, "");
if (!raw) throw new Error("baseUrl 不能为空");
let u;
try {
u = new URL(raw);
} catch {
throw new Error("baseUrl 不是合法 URL");
}
if (u.protocol !== "http:" && u.protocol !== "https:") {
throw new Error("baseUrl 仅支持 http/https");
}
return u.origin + (u.pathname === "/" ? "" : u.pathname.replace(/\/+$/, ""));
}
/**
* @param {string} baseUrl
* @param {string} path
* @param {object} [opts]
* @param {string} [opts.token]
* @param {string} [opts.method]
* @param {object} [opts.body]
* @param {boolean} [opts.credentials]
*/
export async function vaultFetch(baseUrl, path, opts = {}) {
const base = normalizeBaseUrl(baseUrl);
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
/** @type {Record<string, string>} */
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (opts.token) {
headers.Authorization = `Bearer ${opts.token}`;
}
const init = {
method: opts.method || (opts.body ? "POST" : "GET"),
headers,
credentials: opts.credentials ? "include" : "omit",
};
if (opts.body !== undefined) {
init.body = JSON.stringify(opts.body);
}
let res;
try {
res = await fetch(url, init);
} catch (e) {
const err = new Error(
`Failed to fetch ${url}: ${e?.message || e}(若是扩展请求,先确认已授权该 origin 的 host 权限)`
);
err.cause = e;
err.code = "network_error";
throw err;
}
let data = null;
const text = await res.text();
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
if (!res.ok) {
const code = data?.code || data?.error || `http_${res.status}`;
const msg =
data?.message || data?.error || text || `${res.status} ${res.statusText}`;
const err = new Error(String(msg));
err.code = code;
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
/**
* Auth sign-in (sets session cookies when credentials:include).
* @param {string} baseUrl
* @param {{ email: string, password: string }} creds
*/
export async function signIn(baseUrl, creds) {
const email = String(creds.email || "").trim();
const password = String(creds.password || "");
const name = email.includes("@") ? email.split("@")[0] : email;
// 与 mnote-web /auth 页、scripts/mnote-vault-cli.js 对齐
return vaultFetch(baseUrl, "/api/auth", {
method: "POST",
credentials: true,
body: {
action: "auth:signIn",
args: {
provider: "password",
params: {
password,
flow: "signIn",
account: email,
email,
name,
},
},
},
});
}
/**
* Issue E2 extension token (requires session cookie from signIn).
* @param {string} baseUrl
* @param {object} [opts]
*/
export async function issueExtensionToken(baseUrl, opts = {}) {
return vaultFetch(baseUrl, "/api/vault/extension/token", {
method: "POST",
credentials: true,
body: {
clientId: "chrome-extension",
extensionId: opts.extensionId || null,
ttlHours: opts.ttlHours ?? 168,
email: opts.email || null,
},
});
}
/**
* 当前登录账号的默认 local_folder 工作区(每用户固定 my-space)。
* POST /api/local-folder/workspaces/default — 不存在则创建。
* @param {string} baseUrl
* @param {{ token?: string, credentials?: boolean }} [opts]
* @returns {Promise<{ rootUri: string, rootPath?: string }>}
*/
export async function ensureDefaultWorkspace(baseUrl, opts = {}) {
const data = await vaultFetch(
baseUrl,
"/api/local-folder/workspaces/default",
{
method: "POST",
credentials: opts.credentials !== false && !opts.token,
token: opts.token,
body: {},
}
);
const ws = data?.workspace || data?.result?.workspace || data?.result || data;
const rootUri = String(ws?.rootUri || "").trim();
if (!rootUri.startsWith("file://")) {
throw new Error(
"无法解析默认工作区 rootUri(请确认账号已登录且 mnote-web 可用)"
);
}
return {
rootUri,
rootPath: ws?.rootPath ? String(ws.rootPath) : undefined,
workspaceId: ws?.manifest?.workspaceId || ws?.workspaceId,
};
}
/**
* @param {VaultConfig} cfg
*/
export async function whoami(cfg) {
return vaultFetch(cfg.baseUrl, "/api/auth/whoami", {
method: "GET",
token: cfg.token,
credentials: !cfg.token,
});
}
/**
* @param {VaultConfig} cfg
*/
export async function listVault(cfg) {
const q = new URLSearchParams({
rootUri: cfg.rootUri,
sourceKind: "local_folder",
});
return vaultFetch(cfg.baseUrl, `/api/vault/list?${q}`, {
method: "GET",
token: cfg.token,
});
}
/**
* @param {VaultConfig} cfg
* @param {object} item
*/
export async function createItem(cfg, item) {
return vaultFetch(cfg.baseUrl, "/api/vault/items", {
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...item,
},
});
}
/**
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} patch
*/
export async function updateItem(cfg, id, patch) {
return vaultFetch(cfg.baseUrl, `/api/vault/items/${encodeURIComponent(id)}`, {
method: "PATCH",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...patch,
},
});
}
/**
* @param {VaultConfig} cfg
* @param {string} id
*/
export async function getItem(cfg, id) {
const q = new URLSearchParams({
rootUri: cfg.rootUri,
sourceKind: "local_folder",
});
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}?${q}`,
{
method: "GET",
token: cfg.token,
}
);
}
/**
* Reveal a secret field (password etc.) for dedupe / autofill decisions.
* @param {VaultConfig} cfg
* @param {string} id
* @param {{ field?: string, accountId?: string, secretId?: string }} [opts]
*/
export async function revealField(cfg, id, opts = {}) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/reveal`,
{
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
field: opts.field || "password",
accountId: opts.accountId,
secretId: opts.secretId,
},
}
);
}
/**
* PUT session file for credential/account.
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} session
*/
export async function putSession(cfg, id, session) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/session`,
{
method: "PUT",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
source: "chrome_extension",
...session,
},
}
);
}
/**
* @param {VaultConfig} cfg
* @param {string} id
* @param {object} [opts]
*/
export async function shareToAi(cfg, id, opts = {}) {
return vaultFetch(
cfg.baseUrl,
`/api/vault/items/${encodeURIComponent(id)}/share-to-ai`,
{
method: "POST",
token: cfg.token,
body: {
rootUri: cfg.rootUri,
sourceKind: "local_folder",
...opts,
},
}
);
}
/**
* 从 list 条目收集唯一 folderPath(含父路径前缀)。
* @param {Array} items
* @returns {string[]}
*/
export function collectFolderPaths(items) {
/** @type {Record<string, true>} */
const set = {};
const list = Array.isArray(items) ? items : [];
for (const it of list) {
const fp = String(it?.folderPath || "").trim();
if (!fp) continue;
set[fp] = true;
const parts = fp.split("/").filter(Boolean);
for (let i = 1; i < parts.length; i++) {
set[parts.slice(0, i).join("/")] = true;
}
}
return Object.keys(set).sort((a, b) => a.localeCompare(b, "zh"));
}
/**
* Match list items by origin (client-side filter).
* @param {Array} items
* @param {string} pageUrl
*/
export function matchItemsByUrl(items, pageUrl) {
let origin = "";
let hostname = "";
try {
const u = new URL(pageUrl);
origin = u.origin;
hostname = u.hostname || "";
} catch {
return [];
}
const list = Array.isArray(items) ? items : [];
const hostOf = (raw) => {
try {
return new URL(String(raw)).hostname || "";
} catch {
return "";
}
};
const sameSite = (a, b) => {
if (!a || !b) return false;
if (a === b) return true;
return a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
};
return list
.filter((it) => {
if (it && it.status && String(it.status).toLowerCase() === "deleted") {
return false;
}
const urls = [];
if (it.url) urls.push(String(it.url));
if (Array.isArray(it.urls)) urls.push(...it.urls.map(String));
return urls.some((u) => {
try {
const ou = new URL(u);
if (ou.origin === origin) return true;
return sameSite(ou.hostname, hostname);
} catch {
const bare = origin.replace(/^https?:\/\//, "");
return String(u).includes(bare) || sameSite(hostOf(u), hostname);
}
});
})
.sort((a, b) =>
String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))
);
}
/**
* Find a list item that matches page origin + username (best effort).
* @param {Array} items
* @param {string} pageUrl
* @param {string} [username]
*/
export function findMatchingCredential(items, pageUrl, username) {
const matched = matchItemsByUrl(items, pageUrl);
if (!matched.length) return null;
const want = String(username || "")
.trim()
.toLowerCase();
if (!want) return matched[0];
const byUser = matched.find((it) => {
const candidates = [
it.username,
it.email,
...(Array.isArray(it.accounts)
? it.accounts.flatMap((a) => [a?.username, a?.email])
: []),
]
.filter(Boolean)
.map((s) => String(s).trim().toLowerCase());
return candidates.includes(want);
});
return byUser || matched[0];
}
+291
View File
@@ -0,0 +1,291 @@
/**
* Persistent config (sync/local) vs session token.
*/
const SYNC_KEYS = [
"baseUrl",
"rootUri",
"rootPath",
"userId",
"email",
"connectedAt",
/** 上次保存选用的分组路径,扩展确认层默认选中 */
"lastFolderPath",
/**
* 去重记忆(不跨浏览器配置同步,仅 local):
* - savedSites: { [origin]: { [usernameKey]: { credentialId, passwordFp, savedAt, hasSession? } } }
* - dismissedSites: { [origin]: { [usernameKey]: dismissedAt }
* - neverAskOrigins: { [origin]: true }
*/
"savedSites",
"dismissedSites",
"neverAskOrigins",
];
const SESSION_KEYS = ["token", "tokenExpiresAt", "jti"];
/** Normalize username for dedupe keys. */
export function normalizeUsernameKey(username) {
return String(username || "")
.trim()
.toLowerCase();
}
/**
* Lightweight non-cryptographic fingerprint for password equality checks.
* Not a security hash — only used to avoid re-prompting the same login.
* @param {string} password
*/
export function passwordFingerprint(password) {
const s = String(password || "");
// FNV-1a 32-bit + length, enough for local dedupe.
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return `fp_${(h >>> 0).toString(16)}_${s.length}`;
}
/**
* @param {string} pageUrl
* @returns {string} origin or ""
*/
export function originFromUrl(pageUrl) {
try {
return new URL(String(pageUrl || "")).origin;
} catch {
return "";
}
}
/**
* @returns {Promise<{savedSites:object, dismissedSites:object, neverAskOrigins:object}>}
*/
export async function loadDedupeState() {
const data = await chrome.storage.local.get([
"savedSites",
"dismissedSites",
"neverAskOrigins",
]);
return {
savedSites:
data.savedSites && typeof data.savedSites === "object"
? data.savedSites
: {},
dismissedSites:
data.dismissedSites && typeof data.dismissedSites === "object"
? data.dismissedSites
: {},
neverAskOrigins:
data.neverAskOrigins && typeof data.neverAskOrigins === "object"
? data.neverAskOrigins
: {},
};
}
/**
* Remember a successful save so the same site+user+password does not re-prompt.
* @param {{ pageUrl: string, username?: string, password?: string, credentialId?: string, hasSession?: boolean }} info
*/
export async function rememberSavedSite(info) {
const origin = originFromUrl(info.pageUrl);
if (!origin) return;
const userKey = normalizeUsernameKey(info.username) || "_";
const state = await loadDedupeState();
const byOrigin = { ...(state.savedSites[origin] || {}) };
byOrigin[userKey] = {
credentialId: info.credentialId || byOrigin[userKey]?.credentialId || "",
passwordFp: passwordFingerprint(info.password || ""),
savedAt: new Date().toISOString(),
hasSession: Boolean(info.hasSession),
};
const savedSites = { ...state.savedSites, [origin]: byOrigin };
// Clear dismiss for this user after explicit save.
const dismissed = { ...(state.dismissedSites[origin] || {}) };
delete dismissed[userKey];
const dismissedSites = { ...state.dismissedSites };
if (Object.keys(dismissed).length) dismissedSites[origin] = dismissed;
else delete dismissedSites[origin];
await chrome.storage.local.set({ savedSites, dismissedSites });
}
/**
* User cancelled save prompt for this site+username (suppress for a while).
* @param {{ pageUrl: string, username?: string, forever?: boolean }} info
*/
export async function rememberDismissedSite(info) {
const origin = originFromUrl(info.pageUrl);
if (!origin) return;
if (info.forever) {
const state = await loadDedupeState();
await chrome.storage.local.set({
neverAskOrigins: { ...state.neverAskOrigins, [origin]: true },
});
return;
}
const userKey = normalizeUsernameKey(info.username) || "_";
const state = await loadDedupeState();
const byOrigin = { ...(state.dismissedSites[origin] || {}) };
byOrigin[userKey] = Date.now();
await chrome.storage.local.set({
dismissedSites: { ...state.dismissedSites, [origin]: byOrigin },
});
}
/**
* Decide whether auto-prompt should be suppressed for this login draft.
* @param {{ pageUrl: string, username?: string, password?: string }} draft
* @param {{ dismissTtlMs?: number }} [opts]
* @returns {Promise<{ suppress: boolean, reason?: string, credentialId?: string, needSessionOnly?: boolean }>}
*/
export async function shouldSuppressPrompt(draft, opts = {}) {
const origin = originFromUrl(draft.pageUrl);
if (!origin) return { suppress: false };
const userKey = normalizeUsernameKey(draft.username) || "_";
const state = await loadDedupeState();
if (state.neverAskOrigins[origin]) {
return { suppress: true, reason: "never_ask_origin" };
}
const dismissTtlMs = opts.dismissTtlMs ?? 14 * 24 * 60 * 60 * 1000;
const dismissedAt = state.dismissedSites?.[origin]?.[userKey];
if (typeof dismissedAt === "number" && Date.now() - dismissedAt < dismissTtlMs) {
return { suppress: true, reason: "recently_dismissed" };
}
const saved = state.savedSites?.[origin]?.[userKey];
if (saved && draft.password) {
const fp = passwordFingerprint(draft.password);
if (saved.passwordFp === fp) {
// Same account+password already saved: do not re-ask for credential.
// If session was never captured, allow a silent session-only update path.
return {
suppress: true,
reason: "already_saved_same_password",
credentialId: saved.credentialId || "",
needSessionOnly: !saved.hasSession,
};
}
}
return { suppress: false };
}
/**
* @returns {Promise<{baseUrl?:string, rootUri?:string, userId?:string, email?:string, connectedAt?:string, lastFolderPath?:string}>}
*/
export async function loadPersistent() {
return chrome.storage.local.get(SYNC_KEYS);
}
/**
* @param {object} data
*/
export async function savePersistent(data) {
/** @type {Record<string, unknown>} */
const patch = {};
for (const k of SYNC_KEYS) {
if (data[k] !== undefined) patch[k] = data[k];
}
await chrome.storage.local.set(patch);
}
/**
* @returns {Promise<{token?:string, tokenExpiresAt?:string, jti?:string}>}
*/
export async function loadSession() {
// Prefer session storage (cleared on browser restart) when available.
if (chrome.storage.session) {
return chrome.storage.session.get(SESSION_KEYS);
}
return chrome.storage.local.get(SESSION_KEYS);
}
/**
* @param {object} data
*/
export async function saveSession(data) {
/** @type {Record<string, unknown>} */
const patch = {};
for (const k of SESSION_KEYS) {
if (data[k] !== undefined) patch[k] = data[k];
}
if (chrome.storage.session) {
await chrome.storage.session.set(patch);
} else {
await chrome.storage.local.set(patch);
}
}
export async function clearSession() {
if (chrome.storage.session) {
await chrome.storage.session.remove(SESSION_KEYS);
}
await chrome.storage.local.remove(SESSION_KEYS);
}
/**
* Full runtime config for API calls.
* @returns {Promise<{baseUrl:string, rootUri:string, token?:string, userId?:string, email?:string, connected:boolean}>}
*/
export async function loadConfig() {
const [p, s] = await Promise.all([loadPersistent(), loadSession()]);
const baseUrl = (p.baseUrl || "").trim();
const rootUri = (p.rootUri || "").trim();
const token = (s.token || "").trim();
const connected = Boolean(baseUrl && rootUri && token);
return {
baseUrl,
rootUri,
token: token || undefined,
userId: p.userId,
email: p.email,
connected,
tokenExpiresAt: s.tokenExpiresAt,
jti: s.jti,
lastFolderPath: p.lastFolderPath || "",
};
}
/**
* 规范化逻辑分组路径(与 vault store normalize_folder_path 对齐)。
* @param {string} [raw]
* @returns {string}
*/
export function normalizeFolderPath(raw) {
const s = String(raw || "").trim();
if (!s) return "";
const parts = s
.split(/[/\\]+/)
.map((p) => p.trim())
.filter((p) => p && p !== "." && p !== "..");
return parts.join("/");
}
/**
* 记住用户上次选用的分组。
* @param {string} folderPath
*/
export async function saveLastFolderPath(folderPath) {
const fp = normalizeFolderPath(folderPath);
await savePersistent({ lastFolderPath: fp });
}
/**
* @param {boolean} connected
* @param {string} [label]
*/
export async function setBadge(connected, label) {
try {
if (connected) {
await chrome.action.setBadgeText({ text: "ON" });
await chrome.action.setBadgeBackgroundColor({ color: "#0a7" });
if (label) {
await chrome.action.setTitle({ title: `MNote Vault · ${label}` });
}
} else {
await chrome.action.setBadgeText({ text: "—" });
await chrome.action.setBadgeBackgroundColor({ color: "#888" });
await chrome.action.setTitle({ title: "MNote Vault · 未连接" });
}
} catch {
// ignore in non-extension env
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"manifest_version": 3,
"name": "MNote Vault",
"version": "0.2.3",
"description": "将外站登录/注册账号与浏览器登录态保存到 MNote 密码箱(提交缓存 → 登录后确认;每账号独立 session 文件)。",
"permissions": [
"storage",
"activeTab",
"scripting",
"alarms",
"cookies",
"tabs"
],
"optional_host_permissions": [
"http://*/*",
"https://*/*"
],
"host_permissions": [
"http://*/*",
"https://*/*"
],
"background": {
"service_worker": "background.js",
"type": "module"
},
"action": {
"default_popup": "popup.html",
"default_title": "MNote Vault",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_page": "options.html",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
]
}
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>MNote Vault · 连接设置</title>
<style>
:root {
color-scheme: dark;
--bg: #0f1115;
--card: #1a1d23;
--border: #3a3f4b;
--text: #e8eaed;
--muted: #9aa0a6;
--accent: #3b82f6;
--ok: #4ade80;
--err: #f87171;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.45;
padding: 24px;
}
.wrap { max-width: 520px; margin: 0 auto; }
h1 { font-size: 1.25rem; margin: 0 0 4px; }
.sub { color: var(--muted); font-size: 0.85rem; margin-bottom: 20px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
label {
display: block;
font-size: 0.75rem;
color: var(--muted);
margin: 10px 0 4px;
}
input {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: #0f1115;
color: var(--text);
font-size: 0.9rem;
}
.actions { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
button {
border: 0;
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.primary { background: var(--accent); color: #fff; }
.secondary { background: #2a2f3a; color: var(--text); }
.danger { background: #7f1d1d; color: #fecaca; }
#status {
margin-top: 12px;
font-size: 0.85rem;
min-height: 1.2em;
}
#status.ok { color: var(--ok); }
#status.err { color: var(--err); }
code {
background: #0f1115;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.8rem;
}
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 8px; }
details.adv {
margin-top: 14px;
border-top: 1px solid var(--border);
padding-top: 10px;
}
details.adv summary {
cursor: pointer;
color: var(--muted);
font-size: 0.8rem;
user-select: none;
}
details.adv[open] summary { margin-bottom: 6px; }
</style>
</head>
<body>
<div class="wrap">
<h1>MNote Vault</h1>
<p class="sub">连接远程 MNote → 签发扩展 token → 外站保存账号与登录态</p>
<div class="card">
<label for="baseUrl">MNote 基址 (baseUrl)</label>
<input id="baseUrl" type="url" placeholder="http://127.0.0.1:3000" />
<label for="email">MNote 账号 / 邮箱</label>
<input id="email" type="email" autocomplete="username" />
<label for="password">密码(仅登录瞬间使用,不落盘)</label>
<input id="password" type="password" autocomplete="current-password" />
<details class="adv" id="advRoot">
<summary>高级:覆盖工作区 rootUri(一般不用)</summary>
<label for="rootUri">rootUri</label>
<input
id="rootUri"
type="text"
placeholder="留空 = 自动使用账号默认 my-space"
/>
<p class="hint">
每个账号登录后都有固定默认工作区(
<code>…/users/&lt;actor&gt;/workspaces/my-space</code>
)。留空即可;仅当你要把密码箱写到别的 local_folder 时才填
<code>file://…</code>
</p>
</details>
<div class="actions">
<button type="button" class="primary" id="connect">连接并登录</button>
<button type="button" class="secondary" id="test">测试连接</button>
<button type="button" class="danger" id="disconnect">断开</button>
</div>
<div id="status"></div>
<p class="hint" id="workspaceHint"></p>
</div>
<div class="card">
<strong>使用说明</strong>
<ol class="hint" style="padding-left: 1.2em">
<li>确保 mnote-web 已启动;本机可用 <code>http://127.0.0.1:3000</code>,外网填 frp 完整地址(含端口)。</li>
<li>填 baseUrl + 账号密码 →「连接并登录」。外网首次会弹出「允许访问该网站」,必须点允许。</li>
<li>rootUri 自动解析,不必手填。</li>
<li>外站出现密码框后会弹出保存确认;默认勾选保存 Cookie。</li>
<li>也可点工具栏图标手动「保存当前页」。</li>
</ol>
</div>
</div>
<script type="module" src="options.js"></script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
import { normalizeBaseUrl, vaultFetch } from "./lib/api.js";
import { loadConfig, loadPersistent } from "./lib/storage.js";
const $ = (id) => document.getElementById(id);
function setStatus(text, kind) {
const el = $("status");
el.textContent = text || "";
el.className = kind || "";
}
function setWorkspaceHint(rootUri, rootPath, source) {
const el = $("workspaceHint");
if (!el) return;
if (!rootUri) {
el.textContent = "";
return;
}
const path = rootPath || rootUri.replace(/^file:\/\//, "");
const src =
source === "default"
? "账号默认工作区"
: source === "override"
? "手动覆盖"
: "已保存";
el.innerHTML = `密码箱写入:<code>${path}</code>${src}`;
}
/**
* 必须在用户点击手势内调用:MV3 从 service worker 弹权限常被静默拒绝,
* 外网 baseUrl(含非标端口 frp)没有 host 权限时 fetch 就是 Failed to fetch。
* 同时申请 http(s)://*/*否则外站 Cookie 读不到登录态无法保存
* @param {string} baseUrl
*/
async function requestBaseUrlPermission(baseUrl) {
const origin = new URL(normalizeBaseUrl(baseUrl)).origin;
// Broad first so cookie capture works on every login site after connect.
const broad = ["http://*/*", "https://*/*", `${origin}/*`];
try {
const haveBroad = await chrome.permissions.contains({ origins: broad });
if (haveBroad) return true;
const ok = await chrome.permissions.request({ origins: broad });
if (ok) return true;
} catch {
/* fall through to narrow */
}
const origins = [`${origin}/*`];
const have = await chrome.permissions.contains({ origins });
if (have) return true;
try {
return await chrome.permissions.request({ origins });
} catch (e) {
throw new Error(
`无法申请访问权限 ${origin}${e?.message || e}(请在扩展详情里允许该站点;登录态还需「所有网站」)`
);
}
}
function formatFetchError(e, baseUrl) {
const msg = e?.message || String(e);
if (/Failed to fetch|NetworkError|Load failed/i.test(msg)) {
return (
`无法访问 ${baseUrl || "baseUrl"}${msg}` +
`常见原因:① 未点允许扩展访问该站点 ② 外网隧道未通 ③ 证书不受信任。` +
`请重新加载扩展后再点「测试连接」,并允许弹窗中的站点权限。`
);
}
return msg;
}
async function hydrate() {
const p = await loadPersistent();
if (p.baseUrl) $("baseUrl").value = p.baseUrl;
// 默认不展示手填;仅当用户曾覆盖时回填并展开
if (p.rootUri) {
$("rootUri").value = p.rootUri;
const adv = $("advRoot");
if (adv && p.rootUri) {
// 不自动展开;连接后 hint 会显示实际路径
}
}
if (p.email) $("email").value = p.email;
const cfg = await loadConfig();
if (cfg.connected) {
setStatus(
`已连接 · ${cfg.email || cfg.userId || ""} · token 有效`,
"ok"
);
setWorkspaceHint(cfg.rootUri || p.rootUri, p.rootPath, "saved");
} else if (cfg.baseUrl) {
setStatus("已保存配置,但 token 缺失(浏览器重启后需重新登录)", "err");
setWorkspaceHint(p.rootUri, p.rootPath, "saved");
}
}
$("test").onclick = async () => {
setStatus("测试中…");
let baseUrl = "";
try {
baseUrl = normalizeBaseUrl($("baseUrl").value);
const ok = await requestBaseUrlPermission(baseUrl);
if (!ok) {
setStatus(
`已取消访问权限:需要允许扩展访问 ${new URL(baseUrl).origin}`,
"err"
);
return;
}
// whoami 不依赖 /api/health(外网入口可能未挂 health
const data = await vaultFetch(baseUrl, "/api/auth/whoami", {
method: "GET",
credentials: true,
});
setStatus(
`可达 · ${baseUrl} · whoami ok=${Boolean(data?.ok ?? true)}`,
"ok"
);
} catch (e) {
setStatus(formatFetchError(e, baseUrl || $("baseUrl").value), "err");
}
};
$("connect").onclick = async () => {
setStatus("登录中…");
setWorkspaceHint("", "");
const password = $("password").value;
let baseUrl = "";
try {
baseUrl = normalizeBaseUrl($("baseUrl").value);
// 关键:在点击手势内申请 host 权限,再交给 background 登录
const ok = await requestBaseUrlPermission(baseUrl);
if (!ok) {
setStatus(
`已取消访问权限:需要允许扩展访问 ${new URL(baseUrl).origin}(含端口)`,
"err"
);
return;
}
const res = await chrome.runtime.sendMessage({
type: "CONNECT",
baseUrl,
// 留空 → background 调 default workspace
rootUri: ($("rootUri").value || "").trim(),
email: $("email").value,
password,
});
$("password").value = "";
if (!res?.ok) {
setStatus(res?.error || "连接失败", "err");
return;
}
const r = res.result || {};
if (r.rootUri) $("rootUri").value = r.rootUri;
setStatus(
`已连接 · ${r.email || r.userId || ""} · 过期 ${r.expiresAt || "—"}`,
"ok"
);
setWorkspaceHint(r.rootUri, r.rootPath, r.workspaceSource || "default");
} catch (e) {
$("password").value = "";
setStatus(formatFetchError(e, baseUrl || $("baseUrl").value), "err");
}
};
$("disconnect").onclick = async () => {
await chrome.runtime.sendMessage({ type: "DISCONNECT" });
setStatus("已断开", "err");
setWorkspaceHint("", "");
};
hydrate();
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>MNote Vault</title>
<style>
body {
width: 280px;
margin: 0;
padding: 12px;
font-family: system-ui, sans-serif;
background: #1a1d23;
color: #e8eaed;
font-size: 13px;
}
h1 { font-size: 14px; margin: 0 0 8px; }
.status { color: #9aa0a6; margin-bottom: 10px; font-size: 12px; }
.status.on { color: #4ade80; }
.status.off { color: #f87171; }
button {
width: 100%;
margin: 4px 0;
border: 0;
border-radius: 8px;
padding: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: #3b82f6;
color: #fff;
}
button.secondary { background: #2a2f3a; color: #e8eaed; }
#msg { margin-top: 8px; font-size: 11px; color: #9aa0a6; min-height: 1em; }
</style>
</head>
<body>
<h1>MNote Vault</h1>
<div id="status" class="status">检查中…</div>
<button type="button" id="savePage">保存当前页账号</button>
<button type="button" class="secondary" id="updateSession">更新当前页登录态</button>
<button type="button" class="secondary" id="options">打开连接设置</button>
<div id="msg"></div>
<script type="module" src="popup.js"></script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
const $ = (id) => document.getElementById(id);
async function refresh() {
const res = await chrome.runtime.sendMessage({ type: "GET_STATUS" });
const r = res?.result || {};
const el = $("status");
if (r.connected) {
el.className = "status on";
el.textContent = `已连接 · ${r.email || r.userId || ""}`;
} else {
el.className = "status off";
el.textContent = "未连接 — 请先在 Options 登录";
}
}
$("options").onclick = () => {
chrome.runtime.openOptionsPage();
};
$("savePage").onclick = async () => {
$("msg").textContent = "";
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
$("msg").textContent = "无活动标签";
return;
}
try {
// Prefer pending draft (post-login) via background; falls back to live form.
const res = await chrome.runtime.sendMessage({
type: "PROMPT_PENDING",
tabId: tab.id,
});
if (!res?.ok) {
$("msg").textContent = res?.error || "无法打开确认层";
return;
}
$("msg").textContent = "已在页面打开确认层";
window.close();
} catch (e) {
$("msg").textContent =
e?.message ||
"无法注入当前页(受保护页面或未加载)。请在普通 http(s) 页重试。";
}
};
$("updateSession").onclick = async () => {
$("msg").textContent = "匹配条目…";
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.url) {
$("msg").textContent = "无活动标签 URL";
return;
}
try {
const match = await chrome.runtime.sendMessage({
type: "MATCH_URL",
pageUrl: tab.url,
});
const items = match?.result?.items || [];
if (!items.length) {
$("msg").textContent = "无同站条目:请先保存账号";
return;
}
const id = items[0].id;
const res = await chrome.runtime.sendMessage({
type: "UPDATE_SESSION",
credentialId: id,
pageUrl: tab.url,
accountId: "primary",
});
if (!res?.ok) {
$("msg").textContent = res?.error || "更新失败";
return;
}
$("msg").textContent = `已更新登录态 · ${id}`;
} catch (e) {
$("msg").textContent = e?.message || String(e);
}
};
refresh();
+3
View File
@@ -2424,6 +2424,8 @@ dependencies = [
"control-plane",
"core-protocol",
"futures-util",
"hex",
"hmac",
"hyper 1.10.1",
"hyper-util",
"leptos",
@@ -2434,6 +2436,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"sha2",
"time",
"tokio",
"tokio-stream",
+11 -4
View File
@@ -5,8 +5,8 @@ use crate::store::{
self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials,
load_cipher_book, login_session_is_fresh, now_rfc3339, project_item_l0_with_cipher,
project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session,
resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord,
VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
resolve_login_session, resolve_record_secret_field, resolve_secret_with_cipher_book,
VaultCredentialRecord, VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
};
use crate::token::DEFAULT_ACTOR;
use serde_json::{json, Value};
@@ -376,6 +376,7 @@ pub fn put_ai_vault_session(
expires_at: Some(expires.clone()),
last_login_at: Some(now),
source: Some(source.trim().to_string()),
..Default::default()
};
let record = put_login_session(&root, credential_id, session)?;
let _ = append_vault_audit(
@@ -432,8 +433,11 @@ pub fn login_ai_vault_credential(
)
});
// Prefer per-account session files (12-3); frontmatter is fallback inside resolve.
let resolved_session = resolve_login_session(&root, &record, None).ok().flatten();
if playbook.is_human_required() && !force_refresh {
if let Some(sess) = record.login_session.as_ref() {
if let Some(sess) = resolved_session.as_ref() {
if login_session_is_fresh(sess) {
let cookie = sess.cookie_header.clone().unwrap_or_default();
let _ = append_vault_audit(
@@ -452,6 +456,7 @@ pub fn login_ai_vault_credential(
"mode": "session",
"cookieHeader": cookie,
"expiresAt": sess.expires_at,
"accountId": sess.account_id,
"loginPlaybook": playbook,
"transcriptHint": "已复用登录态",
"note": "playbook=human_required;有可用 session 直接复用",
@@ -479,7 +484,7 @@ pub fn login_ai_vault_credential(
}
if !force_refresh {
if let Some(sess) = record.login_session.as_ref() {
if let Some(sess) = resolved_session.as_ref() {
if login_session_is_fresh(sess) {
let cookie = sess.cookie_header.clone().unwrap_or_default();
let _ = append_vault_audit(
@@ -499,6 +504,7 @@ pub fn login_ai_vault_credential(
"cookieHeader": cookie,
"expiresAt": sess.expires_at,
"source": sess.source,
"accountId": sess.account_id,
"loginPlaybook": {
"mode": playbook.mode,
"preferredAccount": playbook.preferred_account,
@@ -736,6 +742,7 @@ pub fn login_ai_vault_credential(
expires_at: Some(expires.clone()),
last_login_at: Some(now),
source: Some("api".into()),
..Default::default()
};
let _ = put_login_session(&root, credential_id, session)?;
let _ = append_vault_audit(
+757 -32
View File
@@ -168,8 +168,32 @@ pub struct VaultLoginPlaybook {
pub human_note: Option<String>,
}
/// Structured cookie from chrome.cookies / browser capture (secret values).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct VaultSessionCookie {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secure: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_site: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expiration_date: Option<f64>,
}
/// Captured browser/API session for multi-agent reuse (cookie header is secret).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
///
/// **Disk truth (12-3):** `sessions/{credId}/{accountId}.json`.
/// Frontmatter `loginSession` is read-only fallback for pre-12-3 data.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct VaultLoginSession {
/// Full Cookie request header value (secret).
@@ -179,11 +203,44 @@ pub struct VaultLoginSession {
pub expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_login_at: Option<String>,
/// api | browser | human_bridge
/// chrome_extension | api | browser | human_bridge | login_api
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cookies: Vec<VaultSessionCookie>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<u64>,
}
/// On-disk session file schema (`mnote.vault.session.v1`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct VaultSessionFile {
pub schema: String,
pub credential_id: String,
pub account_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie_header: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cookies: Vec<VaultSessionCookie>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_login_at: Option<String>,
pub source: String,
pub updated_at: String,
pub revision: u64,
}
pub const VAULT_SESSION_SCHEMA: &str = "mnote.vault.session.v1";
pub const SESSION_ACCOUNT_PRIMARY: &str = "primary";
impl VaultLoginPlaybook {
pub fn default_for_credential(email: Option<&str>, username: Option<&str>) -> Self {
let preferred = if email.map(str::trim).filter(|s| !s.is_empty()).is_some() {
@@ -411,9 +468,11 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
for relative in [
"",
"entries",
"sessions",
"attachments",
"trash",
"trash/entries",
"trash/sessions",
"trash/attachments",
] {
let path = if relative.is_empty() {
@@ -431,6 +490,359 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
Ok(root)
}
/// Normalize account slot for session file name: empty / "primary" → `primary`.
pub fn normalize_session_account_id(account_id: Option<&str>) -> String {
let raw = account_id.map(str::trim).unwrap_or("");
if raw.is_empty() || raw.eq_ignore_ascii_case(SESSION_ACCOUNT_PRIMARY) {
SESSION_ACCOUNT_PRIMARY.to_string()
} else {
// reject path traversal
if raw.contains('/') || raw.contains('\\') || raw.contains("..") {
return SESSION_ACCOUNT_PRIMARY.to_string();
}
raw.to_string()
}
}
/// `sessions/{credId}/` under vault root.
pub fn session_dir_rel(credential_id: &str) -> String {
format!("sessions/{credential_id}")
}
/// `sessions/{credId}/{accountId}.json`
pub fn session_file_rel(credential_id: &str, account_id: Option<&str>) -> String {
let acc = normalize_session_account_id(account_id);
format!("sessions/{credential_id}/{acc}.json")
}
fn session_dir_abs(vault: &Path, credential_id: &str) -> PathBuf {
vault.join("sessions").join(credential_id)
}
fn session_file_abs(vault: &Path, credential_id: &str, account_id: Option<&str>) -> PathBuf {
let acc = normalize_session_account_id(account_id);
session_dir_abs(vault, credential_id).join(format!("{acc}.json"))
}
/// Build Cookie header from structured cookies (preferred when both present).
pub fn cookie_header_from_cookies(cookies: &[VaultSessionCookie]) -> String {
cookies
.iter()
.filter_map(|c| {
let name = c.name.trim();
if name.is_empty() {
return None;
}
let value = c.value.as_deref().unwrap_or("").trim();
Some(format!("{name}={value}"))
})
.collect::<Vec<_>>()
.join("; ")
}
fn session_file_to_login(file: &VaultSessionFile) -> VaultLoginSession {
VaultLoginSession {
cookie_header: file.cookie_header.clone(),
expires_at: file.expires_at.clone(),
last_login_at: file.last_login_at.clone(),
source: Some(file.source.clone()),
origin: file.origin.clone(),
account_id: Some(file.account_id.clone()),
cookies: file.cookies.clone(),
revision: Some(file.revision),
}
}
fn read_session_file_at(path: &Path) -> Result<Option<VaultSessionFile>, WebError> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path).map_err(|error| {
WebError::bad_request_code(
"vault_session_read_failed",
format!("无法读取登录态文件: {error}"),
)
})?;
let file: VaultSessionFile = serde_json::from_str(&raw).map_err(|error| {
WebError::bad_request_code(
"vault_session_parse_failed",
format!("登录态文件 JSON 无效: {error}"),
)
})?;
Ok(Some(file))
}
/// Read one session file (no frontmatter fallback).
pub fn read_session_file(
workspace_root: &Path,
credential_id: &str,
account_id: Option<&str>,
) -> Result<Option<VaultLoginSession>, WebError> {
let vault = ensure_vault_directories(workspace_root)?;
let path = session_file_abs(&vault, credential_id, account_id);
Ok(read_session_file_at(&path)?.map(|f| session_file_to_login(&f)))
}
/// List accountIds that have a session file for this credential.
pub fn list_session_account_ids(
workspace_root: &Path,
credential_id: &str,
) -> Result<Vec<String>, WebError> {
let vault = ensure_vault_directories(workspace_root)?;
let dir = session_dir_abs(&vault, credential_id);
if !dir.exists() {
return Ok(Vec::new());
}
let mut ids = Vec::new();
let entries = fs::read_dir(&dir).map_err(|error| {
WebError::bad_request_code(
"vault_session_list_failed",
format!("无法列出登录态目录: {error}"),
)
})?;
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(stem) = name.strip_suffix(".json") {
if !stem.is_empty() {
ids.push(stem.to_string());
}
}
}
ids.sort();
Ok(ids)
}
/// Resolve session for login: explicit account → primary → accounts[0] → frontmatter.
pub fn resolve_login_session(
workspace_root: &Path,
record: &VaultCredentialRecord,
account_id: Option<&str>,
) -> Result<Option<VaultLoginSession>, WebError> {
let vault = ensure_vault_directories(workspace_root)?;
if let Some(aid) = account_id.map(str::trim).filter(|s| !s.is_empty()) {
if let Some(sess) = read_session_file_at(&session_file_abs(&vault, &record.id, Some(aid)))?
.map(|f| session_file_to_login(&f))
{
return Ok(Some(sess));
}
// explicit account miss: do not fall through to other accounts
return Ok(record
.login_session
.clone()
.filter(|s| s.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false)));
}
// primary file
if let Some(sess) = read_session_file_at(&session_file_abs(
&vault,
&record.id,
Some(SESSION_ACCOUNT_PRIMARY),
))?
.map(|f| session_file_to_login(&f))
{
return Ok(Some(sess));
}
// first multi-account slot
if let Some(first) = record.accounts.first() {
let aid = first.id.trim();
if !aid.is_empty() {
if let Some(sess) =
read_session_file_at(&session_file_abs(&vault, &record.id, Some(aid)))?
.map(|f| session_file_to_login(&f))
{
return Ok(Some(sess));
}
}
}
// any other session file
for aid in list_session_account_ids(workspace_root, &record.id)? {
if aid == SESSION_ACCOUNT_PRIMARY {
continue;
}
if let Some(sess) =
read_session_file_at(&session_file_abs(&vault, &record.id, Some(&aid)))?
.map(|f| session_file_to_login(&f))
{
return Ok(Some(sess));
}
}
// frontmatter fallback (legacy)
Ok(record.login_session.clone().filter(|s| {
s.cookie_header
.as_ref()
.map(|c| !c.trim().is_empty())
.unwrap_or(false)
}))
}
/// Whether credential has any usable session (files or frontmatter).
pub fn credential_has_login_session(
workspace_root: &Path,
record: &VaultCredentialRecord,
) -> bool {
if let Ok(ids) = list_session_account_ids(workspace_root, &record.id) {
if !ids.is_empty() {
return true;
}
}
record
.login_session
.as_ref()
.and_then(|s| s.cookie_header.as_ref())
.map(|c| !c.trim().is_empty())
.unwrap_or(false)
}
fn best_session_expires_at(
workspace_root: &Path,
record: &VaultCredentialRecord,
) -> Option<String> {
if let Ok(Some(sess)) = resolve_login_session(workspace_root, record, None) {
return sess.expires_at;
}
record
.login_session
.as_ref()
.and_then(|s| s.expires_at.clone())
}
fn remove_sessions_dir(vault: &Path, credential_id: &str) {
let dir = session_dir_abs(vault, credential_id);
if dir.exists() {
let _ = fs::remove_dir_all(&dir);
}
}
fn move_sessions_dir(vault: &Path, credential_id: &str, to_trash: bool) {
let active = session_dir_abs(vault, credential_id);
let trash = vault.join("trash").join("sessions").join(credential_id);
if to_trash {
if active.exists() {
let _ = fs::create_dir_all(trash.parent().unwrap_or(vault));
let _ = move_path(&active, &trash);
}
} else if trash.exists() {
let _ = fs::create_dir_all(active.parent().unwrap_or(vault));
let _ = move_path(&trash, &active);
}
}
/// Copy `sessions/{src_id}/**` → `sessions/{dst_id}/**` (share-to-ai).
pub fn copy_session_files(
source_workspace: &Path,
target_workspace: &Path,
source_id: &str,
target_id: &str,
) -> Result<usize, WebError> {
let src_vault = ensure_vault_directories(source_workspace)?;
let dst_vault = ensure_vault_directories(target_workspace)?;
let src_dir = session_dir_abs(&src_vault, source_id);
if !src_dir.exists() {
// migrate frontmatter-only session into target primary.json if present
return Ok(0);
}
let dst_dir = session_dir_abs(&dst_vault, target_id);
fs::create_dir_all(&dst_dir).map_err(|error| {
WebError::bad_request_code(
"vault_session_copy_failed",
format!("无法创建目标登录态目录: {error}"),
)
})?;
let mut count = 0usize;
let entries = fs::read_dir(&src_dir).map_err(|error| {
WebError::bad_request_code(
"vault_session_copy_failed",
format!("无法读取源登录态目录: {error}"),
)
})?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let name = entry.file_name();
let dst_path = dst_dir.join(&name);
// rewrite credentialId inside file
if let Ok(Some(mut file)) = read_session_file_at(&path) {
file.credential_id = target_id.to_string();
file.updated_at = now_rfc3339();
let json = serde_json::to_string_pretty(&file).map_err(|e| {
WebError::bad_request_code(
"vault_session_copy_failed",
format!("序列化登录态失败: {e}"),
)
})?;
atomic_write_string(&dst_path, &json)?;
count += 1;
} else {
let _ = fs::copy(&path, &dst_path);
count += 1;
}
}
Ok(count)
}
fn atomic_write_string(path: &Path, content: &str) -> Result<(), WebError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法创建目录: {error}"),
)
})?;
}
let tmp = path.with_extension("json.tmp");
{
let mut f = fs::File::create(&tmp).map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法创建临时文件: {error}"),
)
})?;
f.write_all(content.as_bytes()).map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法写入登录态: {error}"),
)
})?;
f.sync_all().ok();
}
fs::rename(&tmp, path).map_err(|error| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("无法落盘登录态: {error}"),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
fn patch_index_session_meta(
workspace_root: &Path,
credential_id: &str,
has_session: bool,
expires_at: Option<String>,
) -> Result<(), WebError> {
let vault = ensure_vault_directories(workspace_root)?;
let mut index = load_index(&vault)?;
if let Some(entry) = index.entries.get_mut(credential_id) {
entry.has_login_session = has_session;
entry.session_expires_at = expires_at;
index.revision = index.revision.saturating_add(1);
index.updated_at = now_rfc3339();
write_index_atomic(&vault, &index)?;
}
Ok(())
}
pub fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
@@ -1165,6 +1577,8 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry {
revision: record.revision,
shared_to_ai: record.shared_to_ai.clone(),
shared_from: record.shared_from.clone(),
// Index is written without workspace path for file scan; use frontmatter
// flag here. List/get projections re-check session files when workspace known.
has_login_session: record
.login_session
.as_ref()
@@ -1183,6 +1597,23 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry {
}
}
/// Refresh has_login_session / session_expires_at from disk session files.
pub fn enrich_index_entry_session(
workspace_root: &Path,
entry: &mut VaultIndexEntry,
) {
if let Ok(ids) = list_session_account_ids(workspace_root, &entry.id) {
if !ids.is_empty() {
entry.has_login_session = true;
if let Ok(record) = get_credential(workspace_root, &entry.id) {
entry.session_expires_at = best_session_expires_at(workspace_root, &record);
}
return;
}
}
// keep frontmatter-derived flags already on entry
}
/// Normalize logical folder path: trim, collapse `//`, strip leading/trailing `/`.
pub fn normalize_folder_path(raw: Option<&str>) -> Option<String> {
let Some(s) = raw.map(str::trim).filter(|v| !v.is_empty()) else {
@@ -2178,6 +2609,7 @@ fn parse_login_session(map: &BTreeMap<String, Value>) -> Option<VaultLoginSessio
expires_at,
last_login_at,
source,
..Default::default()
})
}
@@ -2366,6 +2798,9 @@ pub fn list_credentials(
.filter(|entry| entry.status == status)
.cloned()
.collect();
for entry in &mut items {
enrich_index_entry_session(workspace_root, entry);
}
items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at).then(a.title.cmp(&b.title)));
Ok((index, items))
}
@@ -2590,6 +3025,9 @@ pub fn sync_shared_ai_copy(
});
write_record_with_index(target_workspace, &target)?;
// Keep AI copy sessions in sync with source session files.
let _ = copy_session_files(source_workspace, target_workspace, &source.id, target_id)?;
let mut source_updated = source.clone();
source_updated.shared_to_ai = Some(VaultShareToAi {
target_actor_id: target_actor_id.to_string(),
@@ -2707,6 +3145,27 @@ pub fn share_credential_to_workspace(
item.login_session = None;
write_record_with_index(target_workspace, &item)?;
// Copy per-account session files so Agent login can reuse cookies.
let _ = copy_session_files(source_workspace, target_workspace, id, &item.id)?;
// Legacy frontmatter-only session → write primary on target when no files copied.
if !credential_has_login_session(target_workspace, &item) {
if let Some(sess) = source.login_session.clone().filter(|s| {
s.cookie_header
.as_ref()
.map(|c| !c.trim().is_empty())
.unwrap_or(false)
}) {
let _ = put_login_session(target_workspace, &item.id, sess);
}
}
// Refresh item with resolved session for callers.
if let Ok(mut refreshed) = get_credential(target_workspace, &item.id) {
if let Ok(Some(sess)) = resolve_login_session(target_workspace, &refreshed, None) {
refreshed.login_session = Some(sess);
}
item = refreshed;
}
let mut source_updated = source;
source_updated.shared_to_ai = Some(VaultShareToAi {
target_actor_id: target_actor_id.to_string(),
@@ -3224,6 +3683,7 @@ pub fn soft_delete_credential(
if active_att.exists() {
let _ = move_path(&active_att, &trash_att);
}
move_sessions_dir(&vault, id, true);
for att in &mut record.attachments {
if att.relative_path.starts_with("attachments/") {
att.relative_path = format!("trash/{}", att.relative_path);
@@ -3262,6 +3722,7 @@ pub fn restore_credential(
if trash_att.exists() {
let _ = move_path(&trash_att, &active_att);
}
move_sessions_dir(&vault, id, false);
for att in &mut record.attachments {
if let Some(rest) = att.relative_path.strip_prefix("trash/") {
att.relative_path = rest.to_string();
@@ -3310,6 +3771,12 @@ pub fn purge_credential(workspace_root: &Path, id: &str) -> Result<(), WebError>
if trash_att.exists() {
let _ = fs::remove_dir_all(&trash_att);
}
// purge active + trash session dirs
remove_sessions_dir(&vault, id);
let trash_sess = vault.join("trash").join("sessions").join(id);
if trash_sess.exists() {
let _ = fs::remove_dir_all(&trash_sess);
}
index.entries.remove(id);
index.revision = index.revision.saturating_add(1);
index.updated_at = now_rfc3339();
@@ -3444,21 +3911,41 @@ pub fn project_item_l0_with_cipher(
"isSharedToAi": record.shared_to_ai.is_some(),
"isAiSharedCopy": record.shared_from.is_some()
|| record.tags.iter().any(|t| t == "ai-shared"),
"hasLoginSession": record
.login_session
.as_ref()
.and_then(|s| s.cookie_header.as_ref())
.map(|c| !c.trim().is_empty())
.unwrap_or(false),
"sessionExpiresAt": record
.login_session
.as_ref()
.and_then(|s| s.expires_at.clone()),
"lastLoginAt": record
.login_session
.as_ref()
.and_then(|s| s.last_login_at.clone()),
"hasLoginSession": false,
"sessionExpiresAt": Value::Null,
"lastLoginAt": Value::Null,
});
// Prefer session files when workspace known; else frontmatter.
let resolved_sess = workspace_root
.and_then(|root| resolve_login_session(root, record, None).ok().flatten())
.or_else(|| record.login_session.clone());
if let Some(sess) = resolved_sess.as_ref() {
let has = sess
.cookie_header
.as_ref()
.map(|c| !c.trim().is_empty())
.unwrap_or(false);
item["hasLoginSession"] = json!(has);
item["sessionExpiresAt"] = json!(sess.expires_at);
item["lastLoginAt"] = json!(sess.last_login_at);
// Never project cookieHeader / cookies[].value in L0.
item["loginSession"] = json!({
"hasCookie": has,
"expiresAt": sess.expires_at,
"lastLoginAt": sess.last_login_at,
"source": sess.source,
"accountId": sess.account_id,
"origin": sess.origin,
});
}
if let Some(root) = workspace_root {
if let Ok(ids) = list_session_account_ids(root, &record.id) {
if !ids.is_empty() {
item["sessionAccountIds"] = json!(ids);
item["hasLoginSession"] = json!(true);
}
}
}
if let Some(t) = username_template {
item["usernameTemplate"] = json!(t);
}
@@ -3483,15 +3970,6 @@ pub fn project_item_l0_with_cipher(
"humanNote": pb.human_note,
});
}
// Never project cookieHeader in L0.
if let Some(sess) = &record.login_session {
item["loginSession"] = json!({
"hasCookie": sess.cookie_header.as_ref().map(|c| !c.trim().is_empty()).unwrap_or(false),
"expiresAt": sess.expires_at,
"lastLoginAt": sess.last_login_at,
"source": sess.source,
});
}
item
}
@@ -3559,24 +4037,116 @@ pub fn project_list_entry(entry: &VaultIndexEntry) -> Value {
project_list_entry_with_cipher(entry, None)
}
/// Persist login session on a credential (AI vault session write-back).
/// Persist login session as `sessions/{id}/{accountId}.json` (12-3).
/// Does **not** write cookie into credential frontmatter (legacy field left as-is or cleared).
pub fn put_login_session(
workspace_root: &Path,
id: &str,
session: VaultLoginSession,
) -> Result<VaultCredentialRecord, WebError> {
let mut record = get_credential(workspace_root, id)?;
let account_id = session.account_id.clone();
put_login_session_for_account(workspace_root, id, account_id.as_deref(), session)
}
/// Write session file for a specific account slot (`primary` when omitted).
pub fn put_login_session_for_account(
workspace_root: &Path,
id: &str,
account_id: Option<&str>,
mut session: VaultLoginSession,
) -> Result<VaultCredentialRecord, WebError> {
let record = get_credential(workspace_root, id)?;
if record.status != VaultItemStatus::Active {
return Err(WebError::bad_request_code(
"vault_session_inactive",
"只能给在用条目写入登录态",
));
}
record.login_session = Some(session);
record.updated_at = now_rfc3339();
record.revision = record.revision.saturating_add(1);
write_record_with_index(workspace_root, &record)?;
Ok(record)
// Prefer structured cookies for header when both present.
if !session.cookies.is_empty() {
let rebuilt = cookie_header_from_cookies(&session.cookies);
if !rebuilt.is_empty() {
session.cookie_header = Some(rebuilt);
}
}
let cookie = session
.cookie_header
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
WebError::bad_request_code(
"vault_session_cookie_required",
"cookieHeader 与 cookies[] 至少一个非空",
)
})?;
let acc = normalize_session_account_id(
account_id.or(session.account_id.as_deref()),
);
let vault = ensure_vault_directories(workspace_root)?;
let path = session_file_abs(&vault, id, Some(&acc));
let prev_rev = read_session_file_at(&path)?
.map(|f| f.revision)
.unwrap_or(0);
let now = now_rfc3339();
let expires = session
.expires_at
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| {
let hours = std::env::var("MNOTE_VAULT_SESSION_TTL_HOURS")
.ok()
.and_then(|s| s.trim().parse().ok())
.filter(|h: &i64| *h > 0)
.unwrap_or(168);
(OffsetDateTime::now_utc() + time::Duration::hours(hours))
.format(&Rfc3339)
.unwrap_or_else(|_| now.clone())
});
let source = session
.source
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("api")
.to_string();
let last_login = session
.last_login_at
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| now.clone());
let file = VaultSessionFile {
schema: VAULT_SESSION_SCHEMA.into(),
credential_id: id.to_string(),
account_id: acc.clone(),
cookie_header: Some(cookie.to_string()),
cookies: session.cookies.clone(),
origin: session.origin.clone(),
expires_at: Some(expires.clone()),
last_login_at: Some(last_login),
source,
updated_at: now,
revision: prev_rev.saturating_add(1),
};
let json = serde_json::to_string_pretty(&file).map_err(|e| {
WebError::bad_request_code(
"vault_session_write_failed",
format!("序列化登录态失败: {e}"),
)
})?;
atomic_write_string(&path, &json)?;
// Index meta only — do not bump credential revision / rewrite md for cookie.
patch_index_session_meta(workspace_root, id, true, Some(expires))?;
// Return record with resolved session for callers that inspect login_session.
let mut out = record;
out.login_session = Some(session_file_to_login(&file));
Ok(out)
}
pub fn put_login_playbook(
@@ -4409,4 +4979,159 @@ mod tests {
assert_eq!(created.accounts[0].password.as_deref(), Some("secret"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn session_file_put_resolve_and_share_copy() {
let src = temp_root();
let dst = temp_root();
let created = create_credential(
&src,
VaultCreateInput {
title: "session-file-test".into(),
url: Some("https://example.com/login".into()),
username: Some("user@ex.com".into()),
password: Some("secret".into()),
..Default::default()
},
)
.unwrap();
let put = put_login_session(
&src,
&created.id,
VaultLoginSession {
// When cookies[] present, header is rebuilt from them (preferred).
cookie_header: Some("ignored-if-cookies-present".into()),
expires_at: Some("2099-01-01T00:00:00Z".into()),
last_login_at: Some("2026-07-24T12:00:00Z".into()),
source: Some("chrome_extension".into()),
origin: Some("https://example.com".into()),
account_id: None,
cookies: vec![
VaultSessionCookie {
name: "sid".into(),
value: Some("abc".into()),
domain: Some(".example.com".into()),
path: Some("/".into()),
secure: Some(true),
http_only: Some(true),
same_site: Some("lax".into()),
expiration_date: None,
},
VaultSessionCookie {
name: "csrf".into(),
value: Some("xyz".into()),
domain: Some(".example.com".into()),
path: Some("/".into()),
secure: Some(true),
http_only: Some(false),
same_site: Some("lax".into()),
expiration_date: None,
},
],
revision: None,
},
)
.unwrap();
assert_eq!(
put.login_session
.as_ref()
.and_then(|s| s.cookie_header.as_deref()),
Some("sid=abc; csrf=xyz")
);
let path = vault_root(&src)
.join("sessions")
.join(&created.id)
.join("primary.json");
assert!(path.exists(), "session file should exist at {:?}", path);
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.contains("mnote.vault.session.v1"));
assert!(raw.contains("chrome_extension"));
// credential md must not contain cookie plaintext after file write
let md = fs::read_to_string(vault_root(&src).join(&created.relative_path)).unwrap();
assert!(!md.contains("sid=abc"));
let resolved = resolve_login_session(&src, &created, None).unwrap().unwrap();
assert_eq!(
resolved.cookie_header.as_deref(),
Some("sid=abc; csrf=xyz")
);
assert!(login_session_is_fresh(&resolved));
let (_, list) = list_credentials(&src, VaultItemStatus::Active).unwrap();
let entry = list.iter().find(|e| e.id == created.id).unwrap();
assert!(entry.has_login_session);
let l0 = project_item_l0_with_cipher(&created, Some(&src));
assert_eq!(l0["hasLoginSession"], true);
assert!(!l0.to_string().contains("sid=abc"));
let _ = put_login_session_for_account(
&src,
&created.id,
Some("acc_alt"),
VaultLoginSession {
cookie_header: Some("sid=alt".into()),
expires_at: Some("2099-06-01T00:00:00Z".into()),
source: Some("api".into()),
..Default::default()
},
)
.unwrap();
let ids = list_session_account_ids(&src, &created.id).unwrap();
assert!(ids.contains(&"primary".to_string()));
assert!(ids.contains(&"acc_alt".to_string()));
let shared = share_credential_to_workspace(
&src,
&dst,
&created.id,
Some("from-user"),
&[],
false,
"user-a",
"ai-agent",
)
.unwrap();
let dst_ids = list_session_account_ids(&dst, &shared.item.id).unwrap();
assert!(
dst_ids.contains(&"primary".to_string()),
"share must copy session files"
);
let dst_sess = resolve_login_session(&dst, &shared.item, None)
.unwrap()
.unwrap();
assert!(dst_sess
.cookie_header
.as_ref()
.map(|c| c.contains("sid=abc"))
.unwrap_or(false));
soft_delete_credential(&src, &created.id).unwrap();
assert!(!vault_root(&src)
.join("sessions")
.join(&created.id)
.exists());
assert!(vault_root(&src)
.join("trash")
.join("sessions")
.join(&created.id)
.exists());
restore_credential(&src, &created.id).unwrap();
assert!(vault_root(&src)
.join("sessions")
.join(&created.id)
.exists());
soft_delete_credential(&src, &created.id).unwrap();
purge_credential(&src, &created.id).unwrap();
assert!(!vault_root(&src)
.join("trash")
.join("sessions")
.join(&created.id)
.exists());
let _ = fs::remove_dir_all(&src);
let _ = fs::remove_dir_all(&dst);
}
}
+4 -1
View File
@@ -30,7 +30,10 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] }
tower = "0.5"
base64 = "0.22"
comrak = { version = "0.52", default-features = false }
hex = "0.4"
hmac = "0.12"
notify = "8.2.0"
time = { version = "0.3", features = ["formatting", "local-offset"] }
sha2 = "0.10"
time = { version = "0.3", features = ["formatting", "local-offset", "parsing"] }
uuid = { version = "1", features = ["v4"] }
zip = "2"
@@ -1,127 +0,0 @@
// AgentStreamEventRouter — 统一的 agent 流式事件状态机
// 供 Page AI sidebar runtime 和 document editor adapter runtime 共用
//
// 事件状态:
// init — 建立 request_id 绑定
// loading — message_delta / tool_call_delta → 增量更新
// stream_event — tool-started / tool-finished / agent_state
// finished — 正常终止
// interrupted — 中断(等待审批/恢复)
// error — 错误终止
// approval_required — 等待用户确认
export const AGENT_EVENT_STATES = {
INIT: 'init',
LOADING: 'loading',
STREAM_EVENT: 'stream_event',
FINISHED: 'finished',
INTERRUPTED: 'interrupted',
ERROR: 'error',
APPROVAL_REQUIRED: 'approval_required',
};
export const TERMINAL_STATES = new Set([
AGENT_EVENT_STATES.FINISHED,
AGENT_EVENT_STATES.INTERRUPTED,
AGENT_EVENT_STATES.ERROR,
]);
export function isTerminalState(status) {
return TERMINAL_STATES.has(status);
}
// 解析 SSE 帧为 eventName + payloadText
export function parseSSEFrames(buffer, lastBoundary) {
var frames = buffer.split('\n\n');
var remaining = frames.pop() || '';
var events = [];
frames.forEach(function(frame) {
var eventName = '';
var dataLines = [];
frame.split('\n').forEach(function(line) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
});
var payloadText = dataLines.join('\n');
if (!eventName && payloadText) {
try {
var parsed = JSON.parse(payloadText);
eventName = parsed && parsed.event ? String(parsed.event) : '';
} catch (_) {}
}
if (eventName) events.push({ eventName: eventName, payloadText: payloadText });
});
return { events: events, remaining: remaining };
}
// 从 SSE 流读取事件
export async function readSSEStream(response, onFrame) {
if (!response.body || typeof response.body.getReader !== 'function') return;
var reader = response.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
var result = parseSSEFrames(buffer, '');
buffer = result.remaining;
result.events.forEach(function(evt) {
onFrame(evt.eventName, evt.payloadText);
});
}
}
// 创建标准事件路由器
// handlers: { onInit, onDelta, onToolCall, onToolResult, onAgentState, onTerminal, onApprovalRequired }
export function createAgentStreamRouter(handlers) {
var h = handlers || {};
return function routeEvent(eventName, payloadText) {
var payload = null;
try { payload = JSON.parse(payloadText || 'null'); } catch (_) {}
var status = (payload && payload.status) || eventName;
switch (status) {
case AGENT_EVENT_STATES.INIT:
if (h.onInit) h.onInit(payload);
break;
case AGENT_EVENT_STATES.LOADING:
if (h.onDelta) h.onDelta(payload);
if (h.onToolCall) {
var toolChunks = (payload && payload.tool_call_chunks) || (payload && payload.msg && payload.msg.tool_call_chunks);
if (toolChunks && toolChunks.length) h.onToolCall(payload);
}
break;
case AGENT_EVENT_STATES.STREAM_EVENT:
if (payload && payload.event === 'tool-finished') {
if (h.onToolResult) h.onToolResult(payload);
} else if (payload && payload.event === 'tool-started') {
// tool start — 可选处理
} else if (payload && payload.agent_state) {
if (h.onAgentState) h.onAgentState(payload.agent_state);
}
break;
case AGENT_EVENT_STATES.FINISHED:
case AGENT_EVENT_STATES.INTERRUPTED:
case AGENT_EVENT_STATES.ERROR:
if (h.onTerminal) h.onTerminal(status, payload);
break;
case AGENT_EVENT_STATES.APPROVAL_REQUIRED:
if (h.onApprovalRequired) h.onApprovalRequired(payload);
break;
default:
// 未知状态:尝试作为 loading 处理
if (payload && (payload.text || payload.delta || payload.content)) {
if (h.onDelta) h.onDelta(payload);
}
break;
}
};
}
@@ -621,7 +621,9 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const conflictSourceLabel = (session) => {
if (!session) return '';
if (session.lastExternalWriteSource === 'mnote-hermes-tool') {
const source = String(session.lastExternalWriteSource || '');
// 兼容历史 externalActor 名;产品面统一称 agent tool
if (source === 'mnote-agent-tool' || source === 'mnote-hermes-tool') {
const runId = String(session.lastExternalWriteRunId || '').trim();
return runId ? `agent run ${runId}` : 'agent run';
}
@@ -1820,7 +1822,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
window.addEventListener('tree:delta', handleTreeExternalChange);
window.addEventListener('tree:resync', handleTreeExternalChange);
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-hermes-tool');
refreshDocumentSessionsFromExternalWrite(event?.detail || {}, 'mnote-agent-tool');
});
window.addEventListener('mnote:local-upload-editor-save-completed', (event) => {
applyLocalUploadEditorSave(event?.detail || {});
@@ -1,359 +0,0 @@
export function createSidebarPageAiMarkdownRuntime(context) {
const { escapeHtml } = context;
function textFromUnknown(value) {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' ');
if (typeof value !== 'object') return '';
var parts = [];
['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
var text = textFromUnknown(value[key]);
if (text) parts.push(text);
}
});
return parts.join(' ');
}
function renderPageAiMarkdownInline(text) {
var html = escapeHtml(String(text || ''));
var codeSpans = [];
var htmlSpans = [];
function stashHtml(value) {
var key = '\u0000HTML' + htmlSpans.length + '\u0000';
htmlSpans.push(value);
return key;
}
html = html.replace(/`([^`\n]+)`/g, function(_, code) {
var key = '\u0000CODE' + codeSpans.length + '\u0000';
codeSpans.push('<code>' + code + '</code>');
return key;
});
html = html.replace(/\[((?:\\.|[^\]\n])+)\]\(([^)\n]+)\)/g, function(match, label, href) {
var normalizedHref = normalizePageAiMarkdownHref(href);
if (!normalizedHref) return match;
var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : '';
return stashHtml('<a href="' + escapeHtml(normalizedHref) + '" target="_blank" rel="noopener noreferrer"' + citationAttr + '>' + unescapePageAiMarkdownLabel(label) + '</a>');
});
html = html.replace(/(^|[\s(])((?:https?:\/\/|\/documents\/|mnote:\/\/open(?:Resource)?)[^\s<>()]+[^\s<>().,;:!?])/g, function(_, prefix, href) {
var normalizedHref = normalizePageAiMarkdownHref(href);
if (!normalizedHref) return prefix + href;
var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : '';
return prefix + stashHtml('<a href="' + escapeHtml(normalizedHref) + '" target="_blank" rel="noopener noreferrer"' + citationAttr + '>' + escapeHtml(normalizedHref) + '</a>');
});
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
codeSpans.forEach(function(value, index) {
html = html.replace('\u0000CODE' + index + '\u0000', value);
});
htmlSpans.forEach(function(value, index) {
html = html.replace('\u0000HTML' + index + '\u0000', value);
});
return html;
}
function normalizePageAiMarkdownHref(value) {
var href = String(value || '').trim()
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
while (href.includes('&amp;')) href = href.replace(/&amp;/g, '&');
if (href.startsWith('<') && href.endsWith('>')) href = href.slice(1, -1).trim();
if (!href || /[\u0000-\u001f<>"']/.test(href)) return '';
var lower = href.toLowerCase();
if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) return '';
if (lower.startsWith('mnote://open')) href = normalizePageAiMnoteOpenHref(href);
href = normalizePageAiLegacyCitationHref(href);
if (href.startsWith('/') || href.startsWith('#')) return href;
if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) return href;
return '';
}
function unescapePageAiMarkdownLabel(value) {
return String(value || '').replace(/\\([\\\[\]()*_`])/g, '$1');
}
function normalizePageAiMnoteOpenHref(href) {
try {
var url = new URL(href);
if (url.protocol !== 'mnote:' || (url.hostname !== 'open' && url.hostname !== 'openResource')) return href;
var path = String(url.searchParams.get('path') || url.searchParams.get('resourcePath') || '').trim();
if (!path) return '';
var params = new URLSearchParams();
var rootUri = String(url.searchParams.get('rootUri') || '').trim();
if (!rootUri) {
try {
rootUri = new URL(window.location.href).searchParams.get('rootUri') || '';
} catch (_locationError) {}
}
if (rootUri) params.set('rootUri', rootUri);
params.set('path', path);
['page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) {
var value = String(url.searchParams.get(key) || '').trim();
if (value) params.set(key, value);
});
return '/api/local-folder/files/open?' + params.toString();
} catch (_error) {
return href;
}
}
function normalizePageAiLegacyCitationHref(href) {
try {
var url = new URL(href, window.location.origin);
var unwrappedHref = normalizePageAiSearchWrappedCitationHref(url);
if (unwrappedHref) return normalizePageAiLegacyCitationHref(unwrappedHref);
if (!isPageAiSameMnoteOrigin(url) && !isPageAiPortableMnoteCitationUrl(url)) return href;
if (url.pathname === '/api/local-folder/files/open') return url.pathname + url.search;
if (!url.pathname.startsWith('/documents/')) return href;
if (isPageAiUnsafeCitationDocumentId(url)) {
var fileOpenHref = normalizePageAiCitationFileOpenHref(url);
if (fileOpenHref) return fileOpenHref;
}
var decodedHash = '';
try {
decodedHash = decodeURIComponent(url.hash || '');
} catch (_decodeError) {
decodedHash = url.hash || '';
}
var marker = '#resource-tab-';
var markerIndex = decodedHash.indexOf(marker);
if (markerIndex < 0 || url.searchParams.get('resourceTab')) {
return url.pathname + url.search + url.hash;
}
var identity = decodedHash.slice(markerIndex + marker.length).replace(/^#+/, '').trim();
if (!identity.startsWith('resource:file:')) return url.pathname + url.search + url.hash;
url.searchParams.set('resourceTab', identity);
if (!url.searchParams.get('sourceKind')) url.searchParams.set('sourceKind', 'local_folder');
var rootUri = String(url.searchParams.get('rootUri') || '').trim();
var prefix = 'resource:file:' + rootUri + ':';
if (rootUri && identity.startsWith(prefix) && !url.searchParams.get('resourcePath')) {
url.searchParams.set('resourcePath', identity.slice(prefix.length).replace(/^\/+/, ''));
}
url.hash = '';
return url.pathname + url.search;
} catch (_error) {
return href;
}
}
function normalizePageAiSearchWrappedCitationHref(url) {
var raw = '';
['wd', 'q', 'query'].some(function(key) {
raw = String(url.searchParams.get(key) || '').trim();
return Boolean(raw);
});
if (!raw) return '';
if (/^documents\//i.test(raw)) raw = '/' + raw;
if (!/^\/documents\//i.test(raw) && !/^https?:\/\/[^/]+\/documents\//i.test(raw) && !/^mnote:\/\/open/i.test(raw)) return '';
if (/^mnote:\/\/open/i.test(raw)) return normalizePageAiMnoteOpenHref(raw);
var nested = new URL(raw, window.location.origin);
if (!nested.pathname.startsWith('/documents/')) return '';
['sourceKind', 'rootUri', 'workspaceId', 'resourceTab', 'resourcePath', 'page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) {
if (nested.searchParams.get(key)) return;
var value = String(url.searchParams.get(key) || '').trim();
if (value) nested.searchParams.set(key, value);
});
return nested.pathname + nested.search;
}
function normalizePageAiCitationFileOpenHref(url) {
var rootUri = String(url.searchParams.get('rootUri') || '').trim();
var path = String(url.searchParams.get('resourcePath') || '').trim();
if (!rootUri || !path) return '';
var params = new URLSearchParams();
params.set('rootUri', rootUri);
params.set('path', path);
['page', 'bbox', 'sourceMapPath', 'blockId', 'evidenceText', 'lineRange', 'charRange'].forEach(function(key) {
var value = String(url.searchParams.get(key) || '').trim();
if (value) params.set(key, value);
});
return '/api/local-folder/files/open?' + params.toString();
}
function isPageAiUnsafeCitationDocumentId(url) {
try {
var raw = String(url.pathname || '').replace(/^\/documents\//, '').split('/')[0] || '';
if (!raw) return false;
var decoded = decodeURIComponent(raw);
return decoded.indexOf('%') >= 0 || decoded.indexOf('\uFFFD') >= 0;
} catch (_error) {
return true;
}
}
function isPageAiPortableMnoteCitationUrl(url) {
try {
if (url.pathname === '/api/local-folder/files/open') {
return Boolean(String(url.searchParams.get('rootUri') || '').trim()) &&
Boolean(String(url.searchParams.get('path') || '').trim());
}
if (!url.pathname.startsWith('/documents/')) return false;
var resourceTab = String(url.searchParams.get('resourceTab') || '').trim();
return String(url.searchParams.get('sourceKind') || '').trim() === 'local_folder' ||
Boolean(String(url.searchParams.get('rootUri') || '').trim()) ||
Boolean(String(url.searchParams.get('resourcePath') || '').trim()) ||
resourceTab.startsWith('resource:file:');
} catch (_error) {
return false;
}
}
function isPageAiMarkdownTableDivider(line) {
var cells = splitPageAiMarkdownTableRow(line);
if (cells.length < 2) return false;
return cells.every(function(cell) {
return /^:?-{3,}:?$/.test(cell.trim());
});
}
function splitPageAiMarkdownTableRow(line) {
var text = String(line || '').trim();
if (!text.includes('|')) return [];
if (text.startsWith('|')) text = text.slice(1);
if (text.endsWith('|')) text = text.slice(0, -1);
return text.split('|').map(function(cell) { return cell.trim(); });
}
function renderPageAiMarkdownTable(lines, startIndex) {
if (startIndex + 1 >= lines.length || !isPageAiMarkdownTableDivider(lines[startIndex + 1])) return null;
var header = splitPageAiMarkdownTableRow(lines[startIndex]);
var divider = splitPageAiMarkdownTableRow(lines[startIndex + 1]);
if (!header.length || header.length !== divider.length) return null;
var rows = [];
var index = startIndex + 2;
while (index < lines.length && lines[index].trim() && lines[index].includes('|')) {
var cells = splitPageAiMarkdownTableRow(lines[index]);
if (!cells.length) break;
rows.push(cells);
index += 1;
}
function cellHtml(tag, value) {
return '<' + tag + '>' + renderPageAiMarkdownInline(value) + '</' + tag + '>';
}
var head = '<thead><tr>' + header.map(function(cell) { return cellHtml('th', cell); }).join('') + '</tr></thead>';
var body = rows.length
? '<tbody>' + rows.map(function(row) {
return '<tr>' + header.map(function(_, cellIndex) { return cellHtml('td', row[cellIndex] || ''); }).join('') + '</tr>';
}).join('') + '</tbody>'
: '';
return {
html: '<div class="wolai-page-ai-markdown-table-wrap"><table>' + head + body + '</table></div>',
nextIndex: index
};
}
function isMnoteCitationHref(href) {
try {
var url = new URL(href, window.location.origin);
if (!isPageAiSameMnoteOrigin(url)) return false;
return url.pathname.startsWith('/documents/') || url.pathname === '/api/local-folder/files/open';
} catch (_error) {
return false;
}
}
function isPageAiSameMnoteOrigin(url) {
try {
var current = new URL(window.location.origin);
if (url.origin === current.origin) return true;
if (url.hostname === 'mnote.local') return true;
var localNames = ['localhost', '127.0.0.1', '::1', 'mnote.local'];
return localNames.indexOf(url.hostname) >= 0 &&
localNames.indexOf(current.hostname) >= 0 &&
String(url.port || defaultPortForProtocol(url.protocol)) === String(current.port || defaultPortForProtocol(current.protocol));
} catch (_error) {
return false;
}
}
function defaultPortForProtocol(protocol) {
return protocol === 'https:' ? '443' : protocol === 'http:' ? '80' : '';
}
function renderPageAiMarkdown(content) {
var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n');
var blocks = [];
var index = 0;
function isBlockBoundary(line) {
return !line.trim() ||
/^```/.test(line.trim()) ||
/^#{1,6}\s+/.test(line) ||
/^\s*[-*]\s+/.test(line) ||
/^\s*\d+[.)]\s+/.test(line);
}
while (index < lines.length) {
var line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
if (/^```/.test(line.trim())) {
index += 1;
var codeLines = [];
while (index < lines.length && !/^```/.test(lines[index].trim())) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
blocks.push('<pre><code>' + escapeHtml(codeLines.join('\n')) + '</code></pre>');
continue;
}
if (/^\s*-{3,}\s*$/.test(line)) {
blocks.push('<hr />');
index += 1;
continue;
}
var table = renderPageAiMarkdownTable(lines, index);
if (table) {
blocks.push(table.html);
index = table.nextIndex;
continue;
}
var heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
var level = Math.min(6, heading[1].length);
blocks.push('<h' + level + '>' + renderPageAiMarkdownInline(heading[2]) + '</h' + level + '>');
index += 1;
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
var unordered = [];
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
unordered.push('<li>' + renderPageAiMarkdownInline(lines[index].replace(/^\s*[-*]\s+/, '')) + '</li>');
index += 1;
}
blocks.push('<ul>' + unordered.join('') + '</ul>');
continue;
}
if (/^\s*\d+[.)]\s+/.test(line)) {
var ordered = [];
while (index < lines.length && /^\s*\d+[.)]\s+/.test(lines[index])) {
ordered.push('<li>' + renderPageAiMarkdownInline(lines[index].replace(/^\s*\d+[.)]\s+/, '')) + '</li>');
index += 1;
}
blocks.push('<ol>' + ordered.join('') + '</ol>');
continue;
}
var paragraph = [];
while (index < lines.length && !isBlockBoundary(lines[index])) {
paragraph.push(renderPageAiMarkdownInline(lines[index]));
index += 1;
}
if (paragraph.length) {
blocks.push('<p>' + paragraph.join('<br />') + '</p>');
} else {
index += 1;
}
}
return blocks.join('') || escapeHtml(String(content || ''));
}
return {
textFromUnknown,
renderPageAiMarkdown,
renderPageAiMarkdownInline
};
}
@@ -1,344 +0,0 @@
export function createSidebarPageAiPermissionRuntime(context) {
const {
documentRef,
pageAiPreviewValue,
pageUiState,
renderPageAiConversation,
} = context;
const doc = documentRef || document;
function pageAiPermissionStorageKey() {
var sessionId = String(pageUiState.pageAiActiveSessionId || 'no-session').trim() || 'no-session';
var path = doc && doc.location ? String(doc.location.pathname || '') : '';
return 'mnote.page_ai.permission_queue.v1:' + path + ':' + sessionId;
}
function pageAiPersistPermissionRequests() {
try {
var pending = pageUiState.pageAiPermissionRequests.filter(function(item) {
return item && item.kind === 'permission' && !item.resolved;
}).slice(-20);
if (window.localStorage) window.localStorage.setItem(pageAiPermissionStorageKey(), JSON.stringify(pending));
} catch (_) {}
}
function pageAiRestorePermissionRequests() {
try {
var raw = window.localStorage ? window.localStorage.getItem(pageAiPermissionStorageKey()) : '';
var items = raw ? JSON.parse(raw) : [];
if (!Array.isArray(items)) items = [];
pageUiState.pageAiPermissionRequests = items.filter(function(item) {
return item && item.kind === 'permission' && !item.resolved;
}).slice(-20);
pageUiState.pageAiPermissionRequests.forEach(function(item) {
var exists = pageUiState.pageAiMessages.some(function(message) {
return message.kind === 'permission' && message.permissionId === item.permissionId;
});
if (!exists) pageUiState.pageAiMessages.push(item);
});
var pending = pageUiState.pageAiPermissionRequests.find(function(item) { return !item.resolved; });
if (pending) {
if (!pageAiApplyPermissionMode(pending)) pageAiShowPermissionDialog(pending);
}
return pageUiState.pageAiPermissionRequests;
} catch (_) {
return [];
}
}
function pageAiPermissionMessage(payload, eventType) {
payload = payload && typeof payload === 'object' ? payload : {};
var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim();
var detail = pageAiPermissionDetail(payload, permissionId);
var toolName = detail.toolName || String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
var args = payload.arguments || payload.args || payload.input || payload.params || payload;
var decision = String(payload.decision || payload.result || '').trim();
if (!decision && eventType === 'permission.denied') decision = 'denied';
if (!decision && eventType === 'permission.allowed') decision = 'allowed';
var options = pageAiPermissionOptions(payload);
return {
role: 'tool',
kind: 'permission',
permissionId: permissionId,
runId: String(payload.runId || payload.run_id || pageUiState.pageAiCurrentRunId || '').trim(),
toolName: toolName,
argsSummary: detail.summary || pageAiPreviewValue(args),
content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'),
resolved: decision === 'denied' || decision === 'allowed',
decision: decision,
options: options,
permissionDetails: detail
};
}
function pageAiPermissionDetail(payload, permissionId) {
var params = payload && payload.params && typeof payload.params === 'object' ? payload.params : {};
var toolCall = params.toolCall && typeof params.toolCall === 'object' ? params.toolCall : {};
var rawInput = toolCall.rawInput && typeof toolCall.rawInput === 'object' ? toolCall.rawInput : {};
var title = String(toolCall.title || payload.toolName || payload.tool || payload.name || '').trim();
var kind = String(toolCall.kind || params.kind || '').trim();
var command = String(rawInput.command || rawInput.cmd || '').trim();
var path = String(rawInput.path || rawInput.file || rawInput.target || '').trim();
var toolName = title || String(payload.toolName || payload.tool || payload.name || 'session/request_permission').trim();
var primary = command || path || String(rawInput.pattern || rawInput.query || '').trim() || title || '权限请求';
var rows = [];
rows.push({ label: '操作', value: toolName });
if (kind) rows.push({ label: '类型', value: pageAiPermissionKindLabel(kind) });
if (command) rows.push({ label: '命令', value: command });
return {
toolName: toolName,
kind: kind,
kindLabel: pageAiPermissionKindLabel(kind),
command: command,
path: path,
summary: primary,
rows: rows
};
}
function pageAiPermissionKindLabel(kind) {
var normalized = String(kind || '').trim().toLowerCase();
if (normalized === 'execute') return '执行命令';
if (normalized === 'edit') return '写入文件';
if (normalized === 'read') return '读取';
if (normalized === 'other') return '其他';
return kind || '';
}
function pageAiPermissionOptions(payload) {
var rawOptions = payload && Array.isArray(payload.options)
? payload.options
: (payload && payload.params && Array.isArray(payload.params.options) ? payload.params.options : []);
return rawOptions.map(function(option) {
option = option && typeof option === 'object' ? option : {};
var optionId = String(option.optionId || option.option_id || option.id || '').trim();
if (!optionId) return null;
return {
optionId: optionId,
name: pageAiPermissionOptionLabel(optionId, option),
kind: String(option.kind || '').trim()
};
}).filter(Boolean).slice(0, 8);
}
function pageAiPermissionOptionLabel(optionId, option) {
var id = String(optionId || '').trim().toLowerCase();
var raw = String((option && (option.name || option.title)) || '').trim();
if (id === 'allow_once') return raw && !/^allow$/i.test(raw) ? raw.replace(/^Allow\b/i, '允许') : '允许一次';
if (id === 'allow_always') return '本会话允许';
if (id === 'allow_persistent') return '始终允许';
if (id === 'reject_once' || id === 'reject') return '拒绝';
if (id === 'cancel') return '取消';
if (id === 'refine' || id === 'revise') return '要求修改';
if (id === 'accept') return '接受';
return raw || optionId || '选择';
}
function pageAiApplyPermissionEvent(eventName, payloadText) {
var payload = null;
try {
payload = JSON.parse(payloadText || 'null');
} catch (_) {
payload = {};
}
var message = pageAiPermissionMessage(payload, eventName);
var existing = pageUiState.pageAiMessages.find(function(item) {
return item.kind === 'permission' && item.permissionId === message.permissionId;
});
if (existing) {
Object.assign(existing, message);
} else {
pageUiState.pageAiMessages.push(message);
}
pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) {
return item.permissionId !== message.permissionId;
}).concat([message]).slice(-20);
pageAiPersistPermissionRequests();
if (!message.resolved && pageAiApplyPermissionMode(message)) {
return;
}
if (!message.resolved) {
pageAiShowPermissionDialog(message);
} else {
pageAiHidePermissionDialog();
}
}
function pageAiReasonixApprovalMode() {
var preferences = pageUiState.pageAiSkillPreferences && typeof pageUiState.pageAiSkillPreferences === 'object'
? pageUiState.pageAiSkillPreferences
: {};
var mode = String(preferences['ai.agent.reasonix.approval_mode'] || 'ask').trim() || 'ask';
return mode === 'allow' || mode === 'deny' ? mode : 'ask';
}
function pageAiApplyPermissionMode(message) {
if (!message || message.resolved) return false;
var mode = pageAiReasonixApprovalMode();
if (mode === 'ask') return false;
var optionId = pageAiPermissionPreferredOptionId(message, mode);
window.setTimeout(function() {
pageAiResolvePermission(message.permissionId, mode, optionId);
}, 0);
return true;
}
function pageAiPermissionPreferredOptionId(message, decision) {
var options = Array.isArray(message && message.options) ? message.options : [];
if (!options.length) return '';
var allowKeywords = ['allow_once', 'allow', 'approve', 'yes', 'allow_always', 'allow_persistent'];
var denyKeywords = ['deny_once', 'reject_once', 'deny', 'reject', 'no', 'cancel', 'stop'];
var keywords = decision === 'allow' ? allowKeywords : denyKeywords;
for (var i = 0; i < keywords.length; i += 1) {
var keyword = keywords[i];
var found = options.find(function(option) {
var text = [option && option.optionId, option && option.kind, option && option.name].map(function(value) {
return String(value || '').toLowerCase();
}).join(' ');
return text.indexOf(keyword) >= 0;
});
if (found && found.optionId) return String(found.optionId || '');
}
if (decision === 'allow') {
var allowFallback = options.find(function(option) { return !pageAiPermissionOptionRejectLike(option); });
return allowFallback && allowFallback.optionId ? String(allowFallback.optionId || '') : '';
}
return '';
}
function pageAiResolvePermission(permissionId, decision, optionId) {
permissionId = String(permissionId || '').trim();
decision = String(decision || '').trim() || 'deny';
optionId = String(optionId || '').trim();
if (!permissionId) return;
var runId = String(pageUiState.pageAiCurrentRunId || '').trim();
if (!runId && pageUiState.pageAiActiveSessionId && Array.isArray(pageUiState.pageAiSessions)) {
var activeSession = pageUiState.pageAiSessions.find(function(session) {
return session && session.id === pageUiState.pageAiActiveSessionId;
});
runId = String(activeSession && (activeSession.runId || activeSession.hostRunId) || '').trim();
}
if (!runId && Array.isArray(pageUiState.pageAiPermissionRequests)) {
var pending = pageUiState.pageAiPermissionRequests.find(function(item) {
return item && item.permissionId === permissionId;
});
runId = String(pending && pending.runId || '').trim();
}
if (!runId && doc && doc.documentElement) {
runId = String(doc.documentElement.getAttribute('data-mnote-page-ai-run-id') || doc.documentElement.getAttribute('data-mnote-page-ai-active-host-run-id') || '').trim();
}
if (runId) {
var body = { permissionId: permissionId, decision: decision };
if (optionId) body.optionId = optionId;
fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
}).then(function(response) {
if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status);
}).catch(function(err) {
console.warn('resolve-permission 请求失败', err);
});
} else {
console.warn('resolve-permission: 无活跃 runId,只能本地更新');
}
pageUiState.pageAiMessages.forEach(function(item) {
if (item.kind === 'permission' && item.permissionId === permissionId) {
item.resolved = true;
item.decision = decision;
item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求';
}
});
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
pageAiPersistPermissionRequests();
renderPageAiConversation();
}
function pageAiHidePermissionDialog() {
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
}
function pageAiShowPermissionDialog(message) {
if (!message || message.kind !== 'permission') return;
if (message.resolved) {
pageAiHidePermissionDialog();
return;
}
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
if (!(dialog instanceof HTMLElement)) {
dialog = doc.createElement('div');
dialog.className = 'wolai-page-ai-permission-dialog';
dialog.setAttribute('data-page-ai-permission-dialog', 'true');
dialog.innerHTML = '' +
'<div class="wolai-page-ai-permission-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-ai-memory-title" data-page-ai-permission-tool></div>' +
'<div class="wolai-page-ai-permission-summary" data-page-ai-permission-args></div>' +
'<div class="wolai-page-ai-message-actions" data-page-ai-permission-actions></div>' +
'</div>';
doc.body.appendChild(dialog);
}
var tool = dialog.querySelector('[data-page-ai-permission-tool]');
if (tool instanceof HTMLElement) tool.textContent = "权限 - " + (message.toolName || "session/request_permission");
var args = dialog.querySelector('[data-page-ai-permission-args]');
if (args instanceof HTMLElement) args.innerHTML = pageAiPermissionDetailsHtml(message);
var actions = dialog.querySelector('[data-page-ai-permission-actions]');
if (actions instanceof HTMLElement) actions.innerHTML = pageAiPermissionActionsHtml(message);
dialog.hidden = false;
}
function pageAiPermissionDetailsHtml(message) {
var details = message && message.permissionDetails && typeof message.permissionDetails === 'object'
? message.permissionDetails
: {};
var rows = Array.isArray(details.rows) ? details.rows : [];
if (!rows.length) {
return '<div class="wolai-page-ai-permission-row"><span>请求</span><strong>' + escapeHtml(message.argsSummary || message.content || '') + '</strong></div>';
}
return rows.slice(0, 6).map(function(row) {
return '<div class="wolai-page-ai-permission-row"><span>' + escapeHtml(row.label || '') + '</span><strong>' + escapeHtml(row.value || '') + '</strong></div>';
}).join('');
}
function pageAiPermissionActionsHtml(message) {
var options = Array.isArray(message.options) ? message.options : [];
if (!options.length) {
return '' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>允许</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>拒绝</button>';
}
return options.map(function(option) {
var denyLike = pageAiPermissionOptionRejectLike(option);
return '<button type="button" class="wolai-page-ai-ghost' + (denyLike ? ' wolai-page-ai-ghost--danger' : '') + '" data-page-ai-permission-action="' + (denyLike ? 'deny' : 'allow') + '" data-page-ai-permission-option-id="' + escapeAttr(option.optionId || '') + '" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>' + escapeHtml(option.name || option.optionId || '选择') + '</button>';
}).join('');
}
function pageAiPermissionOptionRejectLike(option) {
var text = String((option && option.optionId) || '') + ' ' + String((option && option.kind) || '') + ' ' + String((option && option.name) || '');
text = text.toLowerCase();
return /reject|deny|cancel|stop|no/.test(text);
}
function escapeAttr(value) {
return escapeHtml(String(value || ''));
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
return {
pageAiPermissionMessage,
pageAiApplyPermissionEvent,
pageAiResolvePermission,
pageAiRestorePermissionRequests,
pageAiHidePermissionDialog,
pageAiShowPermissionDialog
};
}
@@ -1,281 +0,0 @@
export function createSidebarPageAiProfileRuntime(context) {
const {
chatOnlyProfileRegistry,
documentRef,
pageAiAgentRecord,
pageAiCurrentAgentId,
pageAiNormalizeAgentId,
pageUiState,
} = context;
const PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY = Array.isArray(chatOnlyProfileRegistry) ? chatOnlyProfileRegistry : [];
function pageAiProviderLabel(provider) {
if (provider === 'codex') return 'Codex';
if (provider === 'claudecode') return 'ClaudeCode';
return 'Hermes';
}
function pageAiNormalizeArray(value) {
return Array.isArray(value) ? value : [];
}
function pageAiDefaultAcpRuntimes() {
return [
{
name: 'reasonix',
title: 'ACP · Reasonix',
description: '通过 ACP 协议直连 ReasonixDeepSeek 缓存优先)',
model: 'deepseek-chat',
preset: 'auto'
},
{
name: 'hermes',
title: 'ACP · Hermes',
description: '通过 ACP 协议直连 Hermes agent runtime'
}
];
}
function pageAiNormalizeAcpRuntimes(runtimes) {
var byName = {};
pageAiDefaultAcpRuntimes().forEach(function(runtime) {
byName[runtime.name] = Object.assign({}, runtime);
});
pageAiNormalizeArray(runtimes).forEach(function(runtime) {
var name = String(runtime && runtime.name || '').trim();
if (name !== 'reasonix' && name !== 'hermes') return;
byName[name] = Object.assign({}, byName[name] || {}, runtime, { name: name });
});
return ['reasonix', 'hermes'].map(function(name) { return byName[name]; }).filter(Boolean);
}
function pageAiUnwrapUpstream(payload) {
if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream;
return payload || null;
}
function pageAiProfileValue(profile) {
if (profile && typeof profile === 'object') {
return String(profile.profileId || profile.name || profile.profile || profile.id || '').trim();
}
return String(profile || '').trim();
}
function pageAiCurrentProfile() {
var active = String(pageUiState.pageAiActiveProfileName || '').trim();
if (active) return active;
var selected = pageUiState.pageAiProfiles.find(function(profile) {
return profile && profile.active;
});
return pageAiProfileValue(selected) || 'mnoteai';
}
function pageAiRunProfile() {
if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return 'reasonix';
if (pageAiCurrentAgentId() === 'chat_only') return pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile());
return pageAiCurrentProfile();
}
function pageAiMnoteToolModel() {
var doc = documentRef || document;
return String(doc.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash';
}
function pageAiCurrentProfileRecord() {
var active = pageAiCurrentProfile();
return pageUiState.pageAiProfiles.find(function(profile) {
return pageAiProfileValue(profile) === active;
}) || null;
}
function pageAiChatOnlyProfileSpec(profile) {
var profileId = pageAiProfileValue(profile);
var baseProfile = String(profile && profile.baseProfile || '').trim();
var isolatedProfile = String(profile && profile.isolatedProfile || '').trim();
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY.find(function(spec) {
return spec.profileId === profileId || spec.baseProfile === profileId || spec.baseProfile === baseProfile || isolatedProfile.indexOf(spec.baseProfile) >= 0;
}) || null;
}
function pageAiDefaultChatOnlyProfileSpec() {
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY[0] || { profileId: 'shared_deepseek_chat', baseProfile: 'deepseek-chat', label: 'DeepSeek' };
}
function pageAiNormalizeChatOnlyProfileId(profileId) {
var profile = pageAiProfileRecordById(profileId) || { profileId: String(profileId || '').trim(), name: String(profileId || '').trim() };
var spec = pageAiChatOnlyProfileSpec(profile);
return (spec || pageAiDefaultChatOnlyProfileSpec()).profileId;
}
function pageAiProfileDisplayLabel(profile, fallback) {
var alias = String(profile && profile.alias || '').trim();
var displayName = String(profile && (profile.displayName || profile.label || '') || '').trim();
var name = pageAiProfileValue(profile);
return alias || displayName || fallback || name || 'default';
}
function pageAiProfileRecordById(profileId) {
var normalized = String(profileId || '').trim();
if (!normalized) return null;
return pageAiNormalizeArray(pageUiState.pageAiProfiles).find(function(profile) {
return pageAiProfileValue(profile) === normalized;
}) || null;
}
function pageAiSessionAgentFilterValue(session) {
if (String(session && session.source || '') === 'board' || session && session.workerPresetId) return 'board:mnote-page-ai';
var agentId = pageAiNormalizeAgentId(session && session.agentId);
if (agentId === 'reasonix') return 'reasonix';
var profileId = String(session && (session.profileId || session.profile) || '').trim();
if (agentId === 'chat_only') profileId = pageAiNormalizeChatOnlyProfileId(profileId);
return agentId + ':' + profileId;
}
function pageAiSessionAgentLabel(session) {
if (String(session && session.source || '') === 'board' || session && session.workerPresetId) {
var worker = String(session && session.workerPresetId || 'mnote-page-ai-zcode').trim();
var model = String(session && session.modelOverride || '').trim();
return 'Agent Board / ' + worker + (model ? ' / ' + model : '');
}
var agentId = pageAiNormalizeAgentId(session && session.agentId);
if (agentId === 'reasonix') return pageAiAgentRecord(agentId).label || 'Reasonix';
var profileId = String(session && (session.profileId || session.profile) || '').trim();
var profile = pageAiProfileRecordById(profileId) || { profileId: profileId, name: profileId };
var chatOnlySpec = pageAiChatOnlyProfileSpec(profile);
if (agentId === 'chat_only') {
return 'ChatOnly / ' + (chatOnlySpec || pageAiDefaultChatOnlyProfileSpec()).label;
}
if (agentId === 'hermes') return 'Hermes / ' + pageAiProfileDisplayLabel(profile, profileId);
return pageAiAgentRecord(agentId).label;
}
function pageAiSessionPreviewText(session) {
var preview = Array.isArray(session && session.messages) && session.messages.length
? String(session.messages.slice(-1)[0].content || '')
: String(session && (session.snippet || session.preview || '暂无消息') || '暂无消息');
preview = preview.replace(/\s+/g, ' ').trim();
var limit = 96;
return preview.length > limit ? preview.slice(0, limit) + '…' : preview;
}
function pageAiSessionAgentFilterOptions(rows) {
var byValue = { all: '全部 agent' };
pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) {
var value = pageAiSessionAgentFilterValue(session);
byValue[value] = pageAiSessionAgentLabel(session);
});
return Object.keys(byValue).map(function(value) {
return { value: value, label: byValue[value] };
});
}
function pageAiSessionStatusFilterValue(session) {
var status = String(session && session.status || '').trim();
var mode = String(session && (session.runtimeMode || session.mode) || '').trim();
if (pageUiState.pageAiActiveSessionId && session && session.id === pageUiState.pageAiActiveSessionId) return 'active';
if (session && session.replaySeen) return 'replay_seen';
if (mode === 'native_live' || mode === 'cold_resumed' || mode === 'replay_seen') return mode;
if (['running', 'tool_calling', 'queued', 'pending', 'acp_pending'].indexOf(status) >= 0) return 'active';
if (['failed', 'aborted', 'cancelled', 'canceled'].indexOf(status) >= 0) return 'failed';
if (status === 'completed') return 'completed';
return status || 'unknown';
}
function pageAiSessionStatusLabel(value) {
return {
all: '全部状态',
active: 'Active',
completed: 'Completed',
failed: 'Failed',
native_live: 'Reasonix native-live',
cold_resumed: 'Cold resumed',
replay_seen: 'Replay seen',
unknown: 'Unknown'
}[value] || value;
}
function pageAiSessionStatusFilterOptions(rows) {
var byValue = {
all: pageAiSessionStatusLabel('all'),
active: pageAiSessionStatusLabel('active'),
completed: pageAiSessionStatusLabel('completed'),
failed: pageAiSessionStatusLabel('failed'),
native_live: pageAiSessionStatusLabel('native_live'),
cold_resumed: pageAiSessionStatusLabel('cold_resumed'),
replay_seen: pageAiSessionStatusLabel('replay_seen')
};
pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) {
var value = pageAiSessionStatusFilterValue(session);
byValue[value] = pageAiSessionStatusLabel(value);
});
return Object.keys(byValue).map(function(value) {
return { value: value, label: byValue[value] };
});
}
function pageAiVisibleHistorySession(session) {
var source = String(session && session.source || '').trim();
var status = String(session && session.status || '').trim();
var messages = pageAiNormalizeArray(session && session.messages);
return !(source === 'draft' && status === 'draft' && messages.length === 0);
}
function pageAiFilteredHistoryRows(rows) {
var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all';
var statusFilter = String(pageUiState.pageAiSessionStatusFilter || 'all').trim() || 'all';
var normalized = pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession);
return normalized.filter(function(session) {
return (filterValue === 'all' || pageAiSessionAgentFilterValue(session) === filterValue)
&& (statusFilter === 'all' || pageAiSessionStatusFilterValue(session) === statusFilter);
});
}
function pageAiTimestamp(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
var parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
function pageAiUsageSummary(usage) {
if (!usage || typeof usage !== 'object') return '';
var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens;
var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
var parts = [];
if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used));
if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size));
if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output));
return parts.join(' ') || '';
}
return {
pageAiProviderLabel,
pageAiNormalizeArray,
pageAiDefaultAcpRuntimes,
pageAiNormalizeAcpRuntimes,
pageAiUnwrapUpstream,
pageAiProfileValue,
pageAiCurrentProfile,
pageAiRunProfile,
pageAiMnoteToolModel,
pageAiCurrentProfileRecord,
pageAiChatOnlyProfileSpec,
pageAiDefaultChatOnlyProfileSpec,
pageAiNormalizeChatOnlyProfileId,
pageAiProfileDisplayLabel,
pageAiProfileRecordById,
pageAiSessionAgentFilterValue,
pageAiSessionAgentLabel,
pageAiSessionPreviewText,
pageAiSessionAgentFilterOptions,
pageAiSessionStatusFilterValue,
pageAiSessionStatusLabel,
pageAiSessionStatusFilterOptions,
pageAiFilteredHistoryRows,
pageAiTimestamp,
pageAiUsageSummary
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,863 +0,0 @@
export function createSidebarPageAiSessionRuntime(context) {
const {
currentDocumentId,
currentRootUri,
currentSourceKind,
documentRef,
pageAiApplyRuntimeState,
pageAiCurrentAgentId,
pageAiCurrentProfile,
pageAiErrorMessage,
pageAiNormalizeAgentId,
pageAiNormalizeArray,
pageAiNormalizeChatOnlyProfileId,
pageAiPermissionMessage,
pageAiPreviewValue,
pageAiRunProfile,
pageAiSetActiveProfile,
pageAiTimestamp,
pageUiState,
renderPageAiControls,
renderPageAiConversation,
resolveWorkspaceId,
sessionStorageVersion,
windowRef,
} = context;
const doc = documentRef || document;
const win = windowRef || window;
function pageAiStorageKey() {
return 'hermes_page_ai_session:' + currentDocumentId();
}
function pageAiBackendSessionQuery(extra) {
var params = new URLSearchParams();
params.set('source', 'acp');
params.set('workspaceId', resolveWorkspaceId(doc.body));
params.set('documentId', currentDocumentId());
params.set('profile', pageAiRunProfile());
params.set('sourceKind', currentSourceKind());
if (currentRootUri()) params.set('rootUri', currentRootUri());
Object.keys(extra || {}).forEach(function(key) {
var value = extra[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
params.set(key, String(value));
}
});
return params.toString();
}
function pageAiNewSession(title) {
var now = Date.now();
var agentId = pageAiCurrentAgentId();
var profile = agentId === 'chat_only' ? pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile()) : pageAiCurrentProfile();
return {
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
title: title || '新会话',
agentId: agentId,
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? profile : '',
profile: profile,
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
createdAt: now,
updatedAt: now,
source: 'draft',
usage: null,
status: 'draft',
messages: []
};
}
function pageAiNormalizeSessions(sessions) {
return (Array.isArray(sessions) ? sessions : [])
.slice(0, 20)
.map(function(session) {
var sessionStorage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
var persistence = String(session && session.persistence || '').trim();
var agentId = pageAiNormalizeAgentId(session && (session.agentId || session.agent_id));
var profileId = String(session && (session.profileId || session.profile_id || '') || '').trim();
var profile = String(session && session.profile || pageAiCurrentProfile()).trim() || 'default';
if (!profileId && agentId !== 'reasonix') profileId = profile;
if (agentId === 'chat_only') {
profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile);
profile = profileId;
}
return {
schema: String(session && session.schema || '').trim(),
id: String(session && session.id || '').trim() || pageAiNewSession().id,
title: String(session && session.title || '').trim() || '新会话',
agentId: agentId,
profileId: profileId,
profile: profile,
acpRuntime: String(session && session.acpRuntime || 'reasonix').trim(),
createdAt: pageAiTimestamp(session && session.createdAt),
updatedAt: pageAiTimestamp(session && session.updatedAt),
source: String(session && session.source || 'local').trim() || 'local',
persistence: persistence,
sessionStorage: sessionStorage,
permissionLevel: String(session && (session.permissionLevel || session.permission_level) || '').trim(),
shareId: String(session && (session.shareId || session.share_id) || '').trim(),
acpSessionId: String(session && (session.acpSessionId || session.acp_session_id) || '').trim(),
runId: String(session && (session.runId || session.run_id) || '').trim(),
boardRunId: String(session && (session.boardRunId || session.board_run_id || session.runId || session.run_id) || '').trim(),
workflowId: String(session && (session.workflowId || session.workflow_id) || '').trim(),
workerPresetId: String(session && (session.workerPresetId || session.worker_preset_id) || '').trim(),
modelOverride: String(session && (session.modelOverride || session.model_override) || '').trim(),
receiptId: String(session && (session.receiptId || session.receipt_id) || '').trim(),
boardRuns: session && session.boardRuns && typeof session.boardRuns === 'object' ? session.boardRuns : {},
status: String(session && session.status || '').trim(),
runtimeMode: String(session && (session.runtimeMode || session.runtime_mode) || '').trim(),
replaySeen: Boolean(session && session.replaySeen),
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
preview: String(session && session.preview || '').trim(),
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300).map(function(message) { return Object.assign({}, message); }) : []
};
})
.sort(function(a, b) {
if (a.id === pageUiState.pageAiActiveSessionId && b.id !== pageUiState.pageAiActiveSessionId) return -1;
if (b.id === pageUiState.pageAiActiveSessionId && a.id !== pageUiState.pageAiActiveSessionId) return 1;
return Number(b.updatedAt || 0) - Number(a.updatedAt || 0);
});
}
function pageAiNormalizeBackendSessionRow(row) {
if (!row || typeof row !== 'object') return null;
var payload = row.payload && typeof row.payload === 'object' ? row.payload : {};
var sessionId = String(row.sessionId || row.session_id || '').trim();
if (!sessionId) return null;
var title = String(row.title || payload.title || payload.message || '').trim();
if (title.length > 28) title = title.slice(0, 28) + '…';
var persistence = String(row.persistence || payload.persistence || '').trim();
var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim();
var runtime = row.runtime && typeof row.runtime === 'object' ? row.runtime : {};
var status = String(row.status || runtime.status || '').trim();
var hasConversationSignal = String(payload.message || payload.input || row.snippet || '').trim()
|| pageAiNormalizeArray(row.messages).length > 0;
if (status === 'session.created' && !hasConversationSignal) return null;
var agentId = pageAiNormalizeAgentId(row.agentId || row.agent_id || payload.agentId || payload.agent_id);
var profileId = String(row.profileId || row.profile_id || payload.profileId || payload.profile_id || '').trim();
var profile = String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default';
if (!profileId && agentId !== 'reasonix') profileId = profile;
if (agentId === 'chat_only') {
profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile);
profile = profileId;
}
if (!sessionStorage && persistence === 'convex_acp_runtime_store') sessionStorage = 'cloud';
if (!sessionStorage && persistence === 'local_ai_session_jsonl') sessionStorage = String(row.shareId || payload.shareId || '').trim() ? 'local_shared' : 'local_private';
return {
id: sessionId,
title: title || '当前页问答',
agentId: agentId,
profileId: profileId,
profile: profile,
acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(),
createdAt: pageAiTimestamp(row.createdAt || row.created_at),
updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at),
source: sessionStorage === 'local_private' || sessionStorage === 'local_shared' ? 'local' : 'acp',
persistence: persistence,
sessionStorage: sessionStorage,
permissionLevel: String(row.permissionLevel || row.permission_level || payload.permissionLevel || '').trim(),
shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(),
acpSessionId: String(row.acpSessionId || row.acp_session_id || payload.acpSessionId || payload.acp_session_id || '').trim(),
runId: String(row.runId || row.run_id || '').trim(),
status: status,
runtimeMode: String(row.runtimeMode || row.runtime_mode || runtime.mode || payload.reasonixSessionMode || '').trim(),
replaySeen: Boolean(row.replaySeen || row.replay_seen || runtime.replaySeen || payload.replay === true),
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
preview: String(payload.message || row.snippet || '').trim(),
messages: []
};
}
function pageAiMergeSessions(localSessions, backendSessions) {
var byId = {};
pageAiNormalizeSessions(localSessions).forEach(function(session) {
byId[session.id] = session;
});
pageAiNormalizeSessions(backendSessions).forEach(function(session) {
var existing = byId[session.id];
byId[session.id] = Object.assign({}, existing || {}, session, {
messages: session.messages.length ? session.messages : (existing && existing.messages || [])
});
});
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
}
function pageAiDedupeSessions(sessions) {
var byId = {};
var ordered = [];
pageAiNormalizeSessions(sessions).forEach(function(session) {
var id = String(session && session.id || '').trim();
if (!id || byId[id]) return;
byId[id] = true;
ordered.push(session);
});
return ordered;
}
function pageAiSessionStorageLabel(session) {
var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
var persistence = String(session && session.persistence || '').trim();
if (storage === 'local_shared') return '共享会话';
if (storage === 'local_private') return '本地私有';
if (storage === 'sqlite_control_plane' || persistence === 'sqlite_acp_runtime_store') return '账号会话';
if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话';
if (persistence === 'local_ai_session_jsonl') return '本地私有';
return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有';
}
function pageAiLoadSessions() {
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
try {
var raw = win.localStorage.getItem(pageAiStorageKey());
var parsed = raw ? JSON.parse(raw) : null;
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim();
var storageVersion = Number(parsed && parsed.version || 0);
if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
if (activeProfile) pageAiSetActiveProfile(activeProfile);
var storedSessions = pageAiNormalizeSessions(parsed && parsed.sessions);
if (storageVersion >= sessionStorageVersion && storedSessions.length) {
pageUiState.pageAiSessions = storedSessions;
var activeSessionId = String(parsed && parsed.activeSessionId || '').trim();
pageUiState.pageAiActiveSessionId = storedSessions.some(function(session) { return session.id === activeSessionId; }) ? activeSessionId : storedSessions[0].id;
var active = pageAiCurrentSession();
pageUiState.pageAiMessages = active && Array.isArray(active.messages) ? active.messages.slice() : [];
return;
}
} catch (_) {}
var fresh = pageAiNewSession();
pageUiState.pageAiSessions = [fresh];
pageUiState.pageAiActiveSessionId = fresh.id;
pageUiState.pageAiMessages = [];
}
async function pageAiLoadBackendSessions() {
var response = await fetch('/api/page-ai/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
}
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(session) {
var id = String(session && session.id || '').trim();
return id && !id.startsWith('mnote_')
&& (id === pageUiState.pageAiActiveSessionId || (Array.isArray(session.messages) && session.messages.length > 0));
});
if (!backendSessions.length) {
if (!draftSessions.length && !pageUiState.pageAiActiveSessionId) {
var fresh = pageAiNewSession();
pageUiState.pageAiSessions = [fresh];
pageUiState.pageAiActiveSessionId = fresh.id;
pageUiState.pageAiMessages = [];
}
pageUiState.pageAiSessionError = '';
renderPageAiConversation();
renderPageAiControls();
return [];
}
pageUiState.pageAiSessions = pageAiDedupeSessions(backendSessions.concat(draftSessions));
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
}
var active = pageAiCurrentSession();
if (active) {
if (active.profile) pageAiSetActiveProfile(active.profile);
if (!pageUiState.pageAiAcpRuntime && active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages;
}
pageUiState.pageAiSessionError = '';
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
return backendSessions;
}
function pageAiMessageFromRuntimeEvent(event) {
if (!event || typeof event !== 'object') return null;
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
if (payload && (payload.source === 'adapter_replay' || payload.replay === true)) return null;
if (eventType === 'message.delta') {
var delta = String(payload.delta || payload.text || payload.output_text || '');
return delta ? { role: 'assistant', content: delta } : null;
}
if (eventType === 'thought.delta') {
var thought = String(payload.delta || payload.text || '').trim();
return thought ? { role: 'assistant', kind: 'thought', content: thought } : null;
}
if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') {
var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim();
var rawLocations = payload.locations;
return {
role: 'tool',
content: toolName,
toolName: toolName,
toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''),
toolKind: String(payload.kind || ''),
status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'),
argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input),
resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error),
locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [],
traceId: String(payload.traceId || payload.trace_id || ''),
auditId: String(payload.auditId || payload.audit_id || '')
};
}
if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') {
return pageAiPermissionMessage(payload, eventType);
}
if (eventType === 'run.completed') {
var output = String(payload.output || payload.text || '').trim();
return output ? { role: 'assistant', content: output } : null;
}
return null;
}
function pageAiApplyBackendSessionDetail(payload) {
var sessionPayload = payload && payload.session ? payload.session : {};
var runs = pageAiNormalizeArray(sessionPayload.runs);
var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null;
var events = pageAiNormalizeArray(payload && payload.events);
var storedMessages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) {
return {
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
content: String(message.content || '')
};
}).filter(function(message) { return message.content; });
var eventsByRunId = {};
events.forEach(function(event) {
var runId = String(event && (event.runId || event.run_id) || '').trim();
if (!runId) return;
if (!eventsByRunId[runId]) eventsByRunId[runId] = [];
eventsByRunId[runId].push(event);
});
var messages = [];
if (runs.length) {
runs.slice().reverse().forEach(function(run) {
var runPayload = run && run.payload && typeof run.payload === 'object' ? run.payload : {};
var userMessage = String(runPayload.message || runPayload.input || '').trim();
if (userMessage) messages.push({ role: 'user', content: userMessage });
var runId = String(run && (run.runId || run.run_id) || '').trim();
var assistantDelta = '';
var completedOutput = '';
function flushAssistantDelta() {
var content = assistantDelta.trim();
if (content) messages.push({ role: 'assistant', content: content });
assistantDelta = '';
}
pageAiNormalizeArray(eventsByRunId[runId]).forEach(function(event) {
var message = pageAiMessageFromRuntimeEvent(event);
if (!message) return;
if (message.role === 'assistant' && !message.kind) {
var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim();
if (eventType === 'run.completed') {
completedOutput = String(message.content || '').trim();
return;
}
assistantDelta += String(message.content || '');
return;
}
flushAssistantDelta();
messages.push(message);
});
flushAssistantDelta();
if (completedOutput && !messages.some(function(message) {
return message.role === 'assistant' && String(message.content || '').trim() === completedOutput;
})) {
messages.push({ role: 'assistant', content: completedOutput });
}
});
}
if (!messages.length) messages = storedMessages;
var acpSessionId = '';
events.forEach(function(event) {
var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim();
var payload = event && event.payload && typeof event.payload === 'object' ? event.payload : event;
if (eventType === 'session.info.updated') {
var nextAcpSessionId = String(payload && (payload.acpSessionId || payload.acp_session_id) || '').trim();
if (nextAcpSessionId) acpSessionId = nextAcpSessionId;
}
});
var current = pageAiCurrentSession();
if (latest && current) {
Object.assign(current, latest);
}
if (current) {
current.messages = messages.slice(-300);
current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now());
if (acpSessionId) current.acpSessionId = acpSessionId;
if (latest && latest.usage) current.usage = latest.usage;
}
pageAiApplyRuntimeState(payload && payload.runtime);
pageUiState.pageAiMessages = messages.slice(-300);
pageAiPersistSessions();
}
async function pageAiLoadBackendSessionDetail(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return null;
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_detail_failed_' + response.status));
}
pageAiApplyBackendSessionDetail(payload);
renderPageAiConversation();
renderPageAiControls();
return payload;
}
async function pageAiSearchBackendSessions(query) {
var q = String(query || '').trim();
pageUiState.pageAiSessionSearchQuery = q;
if (!q) {
pageUiState.pageAiSessionSearchResults = [];
renderPageAiConversation();
return [];
}
var response = await fetch('/api/page-ai/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_search_failed_' + response.status));
}
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) {
var normalized = pageAiNormalizeBackendSessionRow(row) || {};
normalized.snippet = String(row && row.snippet || normalized.preview || '').trim();
return normalized;
}).filter(function(row) { return row.id; });
renderPageAiConversation();
return pageUiState.pageAiSessionSearchResults;
}
async function pageAiExportBackendSession(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return null;
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/export?' + pageAiBackendSessionQuery({ limit: 200 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_export_failed_' + response.status));
}
var exported = payload.export && typeof payload.export === 'object' ? payload.export : {};
var session = pageAiFindSessionById(sessionId) || pageAiCurrentSession();
if (session) {
session.exportedAt = Date.now();
session.exportMarkdown = String(exported.markdown || '');
session.updatedAt = Date.now();
}
doc.documentElement.setAttribute('data-mnote-page-ai-session-exported', sessionId);
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
return payload;
}
function pageAiPersistSessions() {
pageAiSyncCurrentSessionMessages();
doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
try {
win.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
version: sessionStorageVersion,
activeSessionId: pageUiState.pageAiActiveSessionId,
activeProfileName: pageAiCurrentProfile(),
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
sessions: pageAiNormalizeArray(pageUiState.pageAiSessions).slice(0, 20)
}));
} catch (_) {}
}
async function pageAiEnsureHermesSession(forceCreate) {
pageAiLoadSessions();
var current = pageAiCurrentSession();
var runProfile = pageAiRunProfile();
if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === runProfile) return current;
var response = await fetch('/api/page-ai/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(doc.body),
documentId: currentDocumentId(),
sourceKind: currentSourceKind(),
rootUri: currentRootUri(),
traceId: 'page-ai-' + Date.now().toString(36),
profile: runProfile,
agentId: pageAiCurrentAgentId(),
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '',
title: current && current.title ? current.title : '当前页问答'
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'hermes_session_failed_' + response.status));
}
var session = {
id: String(payload.sessionId || '').trim(),
title: String(payload.title || '当前页问答'),
agentId: pageAiCurrentAgentId(),
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '',
profile: String(payload.profile || runProfile).trim() || 'default',
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
persistence: String(payload.persistence || '').trim(),
sessionStorage: String(payload.sessionStorage || '').trim(),
permissionLevel: String(payload.permissionLevel || '').trim(),
shareId: String(payload.shareId || '').trim(),
acpSessionId: String(payload.acpSessionId || payload.acp_session_id || '').trim(),
createdAt: Date.now(),
updatedAt: Date.now(),
messages: pageUiState.pageAiMessages.slice()
};
var previousSessionId = String(current && current.id || '').trim();
var retainedSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(item) {
var itemId = String(item && item.id || '').trim();
return itemId && itemId !== session.id && itemId !== previousSessionId;
});
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(retainedSessions));
pageUiState.pageAiActiveSessionId = session.id;
pageAiPersistSessions();
renderPageAiConversation();
return session;
}
async function pageAiRestoreHermesSession() {
var current = pageAiCurrentSession();
if (!current || !String(current.id || '').startsWith('mnote_')) return;
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'accept': 'application/json' }
});
if (!response.ok) return;
var payload = await response.json().catch(function(){ return null; });
if (payload && payload.persistence === 'convex_acp_runtime_store') {
pageAiApplyBackendSessionDetail(payload);
renderPageAiConversation();
renderPageAiControls();
return;
}
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
var messages = session && Array.isArray(session.messages) ? session.messages : [];
pageAiApplyRuntimeState(payload && payload.runtime);
if (session && (session.profile || session.profileName)) {
current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile());
}
if (!messages.length) {
renderPageAiControls();
return;
}
pageUiState.pageAiMessages = messages.slice(-300).map(function(message) {
return {
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
content: String(message.content || '')
};
});
current.messages = pageUiState.pageAiMessages.slice();
current.updatedAt = Date.now();
renderPageAiConversation();
renderPageAiControls();
}
function pageAiCurrentSession() {
return pageUiState.pageAiSessions.find(function(session) {
return session.id === pageUiState.pageAiActiveSessionId;
}) || null;
}
function pageAiSelectedSessionIds() {
if (!pageUiState.pageAiSelectedSessionIds || typeof pageUiState.pageAiSelectedSessionIds !== 'object') {
pageUiState.pageAiSelectedSessionIds = {};
}
return pageUiState.pageAiSelectedSessionIds;
}
function pageAiSessionSelected(sessionId) {
sessionId = String(sessionId || '').trim();
return Boolean(sessionId && pageAiSelectedSessionIds()[sessionId]);
}
function pageAiSelectedSessionList() {
var selected = pageAiSelectedSessionIds();
return Object.keys(selected).filter(function(sessionId) {
return selected[sessionId] === true;
});
}
function pageAiSelectedSessionCount() {
return pageAiSelectedSessionList().length;
}
function pageAiToggleSessionSelection(sessionId, selected) {
sessionId = String(sessionId || '').trim();
if (!sessionId) return;
var selectedMap = Object.assign({}, pageAiSelectedSessionIds());
if (selected === false) {
delete selectedMap[sessionId];
} else {
selectedMap[sessionId] = true;
}
pageUiState.pageAiSelectedSessionIds = selectedMap;
renderPageAiConversation();
renderPageAiControls();
}
function pageAiClearSessionSelection() {
pageUiState.pageAiSelectedSessionIds = {};
renderPageAiConversation();
renderPageAiControls();
}
function pageAiSyncCurrentSessionMessages() {
var session = pageAiCurrentSession();
if (!session) return;
var runProfile = pageAiRunProfile();
session.messages = Array.isArray(pageUiState.pageAiMessages) ? pageUiState.pageAiMessages.slice(-300) : [];
session.agentId = pageAiCurrentAgentId();
session.profileId = pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '';
session.profile = runProfile;
session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix';
if (String(session.source || '') === 'board') {
session.schema = session.schema || 'mnote.page_ai.session.v2';
session.workerPresetId = pageUiState.pageAiBoardWorkerId || session.workerPresetId || '';
session.workflowId = pageUiState.pageAiBoardWorkflowId || session.workflowId || '';
session.modelOverride = pageUiState.pageAiBoardModelOverride || session.modelOverride || '';
}
session.updatedAt = Date.now();
}
function pageAiSetActiveSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
pageUiState.pageAiActiveSessionId = session.id;
if (session.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(session.agentId);
if (session.source === 'board') {
if (session.workerPresetId) pageUiState.pageAiBoardWorkerId = session.workerPresetId;
if (session.workflowId) pageUiState.pageAiBoardWorkflowId = session.workflowId;
if (session.modelOverride) pageUiState.pageAiBoardModelOverride = session.modelOverride;
if (session.boardRuns && typeof session.boardRuns === 'object') {
pageUiState.pageAiBoardRunDetails = Object.assign({}, pageUiState.pageAiBoardRunDetails || {}, session.boardRuns);
}
}
if (session.acpRuntime) pageUiState.pageAiAcpRuntime = session.acpRuntime;
if (session.profileId || session.profile) pageAiSetActiveProfile(session.profileId || session.profile);
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
pageUiState.pageAiPage = 'chat';
pageAiPersistSessions();
renderPageAiControls();
renderPageAiConversation();
if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) {
void pageAiLoadBackendSessionDetail(session.id).catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}
}
function pageAiStartNewSession() {
var session = pageAiNewSession();
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions));
pageUiState.pageAiActiveSessionId = session.id;
pageUiState.pageAiMessages = [];
pageUiState.pageAiPage = 'chat';
pageAiPersistSessions();
renderPageAiControls();
renderPageAiConversation();
}
async function pageAiRenameBackendSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
var title = win.prompt('重命名 AI 会话', session.title || '当前页问答');
if (title === null) return;
title = String(title || '').trim();
if (!title) return;
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
body: JSON.stringify({ title: title })
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status));
}
var nextTitle = String((payload.result && payload.result.title) || title);
[pageUiState.pageAiSessions, pageUiState.pageAiSessionSearchResults].forEach(function(list) {
pageAiNormalizeArray(list).forEach(function(item) {
if (String(item && item.id || '').trim() === sessionId) {
item.title = nextTitle;
item.updatedAt = Date.now();
}
});
});
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
function pageAiSessionRequiresBackendDelete(session) {
var id = String(session && session.id || '').trim();
var source = String(session && session.source || '').trim();
return Boolean(id && (
id.startsWith('mnote_')
|| source === 'acp'
|| String(session && session.persistence || '').trim()
|| String(session && (session.sessionStorage || session.session_storage) || '').trim()
));
}
function pageAiFindSessionById(sessionId) {
sessionId = String(sessionId || '').trim();
if (!sessionId) return null;
return pageAiNormalizeArray(pageUiState.pageAiSessions).find(function(item) {
return String(item && item.id || '').trim() === sessionId;
}) || pageAiNormalizeArray(pageUiState.pageAiSessionSearchResults).find(function(item) {
return String(item && item.id || '').trim() === sessionId;
}) || null;
}
function pageAiApplyDeletedSessionIds(sessionIds) {
var deleted = {};
pageAiNormalizeArray(sessionIds).forEach(function(sessionId) {
sessionId = String(sessionId || '').trim();
if (sessionId) deleted[sessionId] = true;
});
pageUiState.pageAiSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter(function(item) {
return !deleted[String(item && item.id || '').trim()];
});
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(pageUiState.pageAiSessionSearchResults).filter(function(item) {
return !deleted[String(item && item.id || '').trim()];
});
var selected = Object.assign({}, pageAiSelectedSessionIds());
Object.keys(deleted).forEach(function(sessionId) { delete selected[sessionId]; });
pageUiState.pageAiSelectedSessionIds = selected;
if (deleted[pageUiState.pageAiActiveSessionId]) {
var next = pageUiState.pageAiSessions[0] || pageAiNewSession();
if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next];
pageUiState.pageAiActiveSessionId = next.id;
pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : [];
}
}
async function pageAiDeleteBackendSessions(sessionIds) {
var ids = pageAiNormalizeArray(sessionIds).map(function(sessionId) {
return String(sessionId || '').trim();
}).filter(Boolean);
if (!ids.length) return;
var sessions = ids.map(pageAiFindSessionById).filter(Boolean);
if (!sessions.length) return;
var confirmText = sessions.length === 1
? '确定删除 AI 会话“' + (sessions[0].title || sessions[0].id) + '”吗?仅删除 MNote 历史,不删除外部 Hermes/provider 会话。'
: '确定删除选中的 ' + String(sessions.length) + ' 个 AI 会话吗?仅删除 MNote 历史,不删除外部 Hermes/provider 会话。';
if (!win.confirm(confirmText)) return;
for (var index = 0; index < sessions.length; index += 1) {
var session = sessions[index];
if (!pageAiSessionRequiresBackendDelete(session)) continue;
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(session.id) + '?' + pageAiBackendSessionQuery({}), {
method: 'DELETE',
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_delete_failed_' + response.status));
}
}
pageAiApplyDeletedSessionIds(sessions.map(function(session) { return session.id; }));
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
async function pageAiDeleteBackendSession(sessionId) {
return pageAiDeleteBackendSessions([sessionId]);
}
async function pageAiDeleteSelectedBackendSessions() {
return pageAiDeleteBackendSessions(pageAiSelectedSessionList());
}
async function pageAiResumeBackendSession(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return;
pageAiSetActiveSession(sessionId);
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_resume_failed_' + response.status));
}
pageAiApplyBackendSessionDetail(payload);
pageUiState.pageAiPage = 'chat';
renderPageAiConversation();
renderPageAiControls();
}
async function pageAiCheckActiveRun(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return null;
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/active-run?' + pageAiBackendSessionQuery({}), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'active_run_failed_' + response.status));
}
if (!payload.active || !payload.run) return null;
var run = payload.run;
var hostRunId = String(run.hostRunId || run.runId || '').trim();
var status = String(run.status || '').trim();
var current = pageAiCurrentSession();
if (current && current.id === sessionId) {
current.runId = hostRunId;
current.status = status || current.status || 'running';
current.updatedAt = Date.now();
}
pageAiApplyRuntimeState(Object.assign({}, run.runtime || {}, {
status: status || 'running',
runId: hostRunId
}));
doc.documentElement.setAttribute('data-mnote-page-ai-active-run-checked', 'true');
if (hostRunId) doc.documentElement.setAttribute('data-mnote-page-ai-active-host-run-id', hostRunId);
pageAiPersistSessions();
renderPageAiControls();
return run;
}
return {
pageAiStorageKey,
pageAiBackendSessionQuery,
pageAiNewSession,
pageAiNormalizeSessions,
pageAiNormalizeBackendSessionRow,
pageAiMergeSessions,
pageAiSessionStorageLabel,
pageAiLoadSessions,
pageAiLoadBackendSessions,
pageAiMessageFromRuntimeEvent,
pageAiApplyBackendSessionDetail,
pageAiLoadBackendSessionDetail,
pageAiSearchBackendSessions,
pageAiPersistSessions,
pageAiEnsureHermesSession,
pageAiRestoreHermesSession,
pageAiCurrentSession,
pageAiSessionSelected,
pageAiToggleSessionSelection,
pageAiClearSessionSelection,
pageAiSelectedSessionCount,
pageAiSyncCurrentSessionMessages,
pageAiSetActiveSession,
pageAiStartNewSession,
pageAiRenameBackendSession,
pageAiExportBackendSession,
pageAiDeleteBackendSession,
pageAiDeleteSelectedBackendSessions,
pageAiResumeBackendSession,
pageAiCheckActiveRun
};
}
@@ -1,310 +0,0 @@
export function createSidebarPageAiSkillRuntime(context) {
const {
pageAiCurrentAgentId,
pageAiCurrentProfile,
pageAiLoadSkills,
pageAiNormalizeArray,
pageAiPersistAiPreference,
pageAiPersistRawAiPreference,
pageAiProfileValue,
pageUiState,
renderPageAiControls,
} = context;
function pageAiSkillSourceOptions() {
var options = [
{ value: 'mnote', group: 'mnote', label: 'MNote 公共能力', profile: '' },
{ value: 'reasonix', group: 'reasonix', label: 'Reasonix skill(查看)', profile: '', readonly: true }
];
pageAiNormalizeArray(pageUiState.pageAiProfiles).forEach(function(profile) {
var profileId = pageAiProfileValue(profile);
if (!profileId || pageAiProfileIsChatOnlySkillSource(profile)) return;
var label = profile.kind === 'shared' ? 'Hermes 共享 skill(查看)' : 'Hermes skill(查看)';
var alias = String(profile.alias || profile.displayName || '').trim();
options.push({
value: 'hermes:' + profileId,
group: 'hermes',
profile: profileId,
label: alias && alias !== label ? label + ' · ' + alias : label,
readonly: true
});
});
return options;
}
function pageAiProfileIsChatOnlySkillSource(profile) {
var profileId = String(pageAiProfileValue(profile) || '').trim().toLowerCase();
var baseProfile = String(profile && profile.baseProfile || '').trim().toLowerCase();
var label = String(profile && (profile.displayName || profile.alias || profile.name) || '').trim().toLowerCase();
var providerKind = String(profile && profile.providerKind || '').trim().toLowerCase();
return profileId.indexOf('chat') >= 0
|| baseProfile.indexOf('chat') >= 0
|| label.indexOf('chat') >= 0
|| providerKind.indexOf('chat') >= 0
|| profileId === 'shared_lite'
|| baseProfile === 'lite';
}
function pageAiDefaultSkillSource() {
return 'mnote';
}
function pageAiNormalizeSkillSource(source) {
var value = String(source || '').trim();
var options = pageAiSkillSourceOptions();
if (options.some(function(option) { return option.value === value; })) return value;
if (value === 'hermes') return 'hermes:' + pageAiCurrentProfile();
if (value === 'mnote_builtin') return 'mnote';
var fallback = pageAiDefaultSkillSource();
if (options.some(function(option) { return option.value === fallback; })) return fallback;
return options.length ? options[0].value : 'mnote';
}
function pageAiCurrentSkillSource() {
var normalized = pageAiNormalizeSkillSource(pageUiState.pageAiActiveSkillSource);
pageUiState.pageAiActiveSkillSource = normalized;
return normalized;
}
function pageAiSetSkillSource(source) {
var normalized = pageAiNormalizeSkillSource(source);
pageUiState.pageAiActiveSkillSource = normalized;
pageAiPersistAiPreference('skills.active_source', normalized);
pageUiState.pageAiSkills = { categories: [], archived: [] };
pageUiState.pageAiSkillError = '';
void pageAiLoadSkills();
renderPageAiControls();
}
function pageAiSkillSourceParts(source) {
var normalized = pageAiNormalizeSkillSource(source);
if (normalized.indexOf('hermes:') === 0) {
return { group: 'hermes', profile: normalized.slice('hermes:'.length), source: normalized };
}
if (normalized === 'reasonix') return { group: 'reasonix', profile: '', source: normalized };
return { group: 'mnote', profile: '', source: 'mnote' };
}
function pageAiCurrentSkillSourceLabel() {
var source = pageAiCurrentSkillSource();
var option = pageAiSkillSourceOptions().find(function(item) { return item.value === source; });
return option ? option.label : source;
}
function pageAiSkillOriginLabel(skill) {
var origin = String(skill && skill.origin || '').trim();
if (origin === 'generated' || String(skill && skill.createdBy || '') === 'agent') return '生成';
if (origin === 'installed') return '安装';
if (origin === 'builtin') return '内置';
if (origin === 'copied') return '本地';
var source = String(skill && skill.source || '').trim();
if (source === 'hub') return '安装';
if (source === 'builtin') return '内置';
if (source === 'reasonix') {
if (origin === 'project') return 'Reasonix 项目';
if (origin === 'global') return 'Reasonix 全局';
return 'Reasonix';
}
return '本地';
}
function pageAiSkillPreferenceKey(group, profile) {
var groupName = String(group || '').trim();
if (groupName === 'mnote') return 'ai.agent.mnote_builtin.skills.enabled';
if (groupName === 'reasonix') return 'ai.agent.reasonix.skills.enabled';
if (groupName === 'hermes') {
var nextProfile = String(profile || pageAiCurrentProfile() || 'default').trim() || 'default';
return 'ai.agent.hermes.profile.' + nextProfile + '.skills.enabled';
}
return '';
}
function pageAiSkillPreferenceTable(group, profile) {
var key = pageAiSkillPreferenceKey(group, profile);
var preferences = pageUiState.pageAiSkillPreferences || {};
var value = preferences[key];
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
return {};
}
function pageAiHermesHideBuiltinPreferenceKey(profile) {
return 'ai.agent.hermes.skills.hide_builtin';
}
function pageAiHideHermesBuiltinSkills(profile) {
var preferences = pageUiState.pageAiSkillPreferences || {};
return preferences[pageAiHermesHideBuiltinPreferenceKey(profile)] === true;
}
function pageAiReasonixMemoryEnabled() {
var preferences = pageUiState.pageAiSkillPreferences || {};
return preferences['ai.agent.reasonix.memory_enabled'] === true;
}
function pageAiSetReasonixMemoryEnabled(enabled) {
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
preferences['ai.agent.reasonix.memory_enabled'] = Boolean(enabled);
pageUiState.pageAiSkillPreferences = preferences;
pageAiPersistRawAiPreference('ai.agent.reasonix.memory_enabled', Boolean(enabled));
renderPageAiControls();
}
function pageAiSetHideHermesBuiltinSkills(enabled, profile) {
var key = pageAiHermesHideBuiltinPreferenceKey(profile);
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
preferences[key] = Boolean(enabled);
pageUiState.pageAiSkillPreferences = preferences;
pageAiPersistRawAiPreference(key, Boolean(enabled));
renderPageAiControls();
}
function pageAiSkillIsBuiltin(skill) {
var origin = String(skill && skill.origin || '').trim();
var source = String(skill && skill.source || '').trim();
return origin === 'builtin' || source === 'builtin';
}
function pageAiToggleableSkillEntries(group, profile) {
var catalogKey = group === 'hermes' && profile ? 'hermes:' + profile : group;
var catalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[catalogKey]
? pageUiState.pageAiSkillCatalogs[catalogKey]
: pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[group]
? pageUiState.pageAiSkillCatalogs[group]
: { categories: [], archived: [] };
var overrides = pageAiSkillPreferenceTable(group, profile);
var result = [];
pageAiNormalizeArray(catalog.categories).forEach(function(category) {
pageAiNormalizeArray(category.skills).forEach(function(skill) {
var id = String(skill.id || skill.name || '').trim();
if (!id) return;
result.push({
group: group,
profile: profile || '',
category: category.name,
categoryTitle: skill.categoryTitle || category.title || category.name || '',
id: id,
name: skill.name || id,
title: skill.title || skill.name || id,
description: skill.description || '',
enabled: group === 'mnote' ? (skill.enabled !== false && overrides[id] !== false) : skill.enabled !== false,
toggleable: group === 'mnote' && skill.toggleable !== false,
source: skill.source || group,
origin: skill.origin || '',
readOnly: group !== 'mnote' || skill.readOnly === true || skill.readonly === true || skill.configurable === false,
builtin: pageAiSkillIsBuiltin(skill),
configurable: skill.configurable !== false,
configScope: skill.configScope || '',
skillKind: skill.skillKind || '',
profileId: skill.profileId || '',
toolNames: skill.toolNames || [],
tools: skill.tools || [],
toolCount: Number(skill.toolCount || 0),
disabledToolCount: Number(skill.disabledToolCount || 0),
status: skill.status || '',
capabilityId: skill.capabilityId || '',
capabilityKind: skill.capabilityKind || '',
uiKind: skill.uiKind || '',
requiresContextRefs: skill.requiresContextRefs || []
});
});
});
pageAiNormalizeArray(catalog.archived).forEach(function(skill) {
var id = String(skill.id || skill.name || '').trim();
if (!id) return;
result.push({
group: group,
profile: profile || '',
category: 'archived',
categoryTitle: skill.categoryTitle || 'archived',
id: id,
name: skill.name || id,
title: skill.title || skill.name || id,
description: skill.description || '',
enabled: group === 'mnote' ? (skill.enabled !== false && overrides[id] !== false) : skill.enabled !== false,
toggleable: group === 'mnote' && skill.toggleable !== false,
source: skill.source || group,
origin: skill.origin || '',
readOnly: group !== 'mnote' || skill.readOnly === true || skill.readonly === true || skill.configurable === false,
builtin: pageAiSkillIsBuiltin(skill),
configurable: skill.configurable !== false,
configScope: skill.configScope || '',
skillKind: skill.skillKind || '',
profileId: skill.profileId || '',
toolNames: skill.toolNames || [],
tools: skill.tools || [],
toolCount: Number(skill.toolCount || 0),
disabledToolCount: Number(skill.disabledToolCount || 0),
status: skill.status || '',
capabilityId: skill.capabilityId || '',
capabilityKind: skill.capabilityKind || '',
uiKind: skill.uiKind || '',
requiresContextRefs: skill.requiresContextRefs || []
});
});
return result;
}
function pageAiAllSkillEntries() {
return []
.concat(pageAiToggleableSkillEntries('mnote', ''))
.concat(pageAiToggleableSkillEntries('reasonix', ''))
.concat(pageAiToggleableSkillEntries('hermes', pageAiCurrentProfile()));
}
function pageAiSkillGroupCollapsed(group) {
var table = pageUiState.pageAiCollapsedSkillGroups || {};
return table[String(group || '').trim()] === true;
}
function pageAiToggleSkillGroup(group) {
var normalized = String(group || '').trim();
if (!normalized) return;
var table = Object.assign({}, pageUiState.pageAiCollapsedSkillGroups || {});
table[normalized] = table[normalized] !== true;
pageUiState.pageAiCollapsedSkillGroups = table;
pageAiPersistRawAiPreference('ai.common.skills.groups.collapsed', table);
renderPageAiControls();
}
function pageAiSetSkillPreference(group, skillId, enabled, profile) {
var key = pageAiSkillPreferenceKey(group, profile);
if (!key || !skillId) return;
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
var current = preferences[key] && typeof preferences[key] === 'object' && !Array.isArray(preferences[key])
? Object.assign({}, preferences[key])
: {};
current[skillId] = Boolean(enabled);
preferences[key] = current;
pageUiState.pageAiSkillPreferences = preferences;
pageAiPersistRawAiPreference(key, current);
}
function pageAiSkillEnabled(skill) {
return skill.enabled !== false;
}
return {
pageAiSkillSourceOptions,
pageAiDefaultSkillSource,
pageAiNormalizeSkillSource,
pageAiCurrentSkillSource,
pageAiSetSkillSource,
pageAiSkillSourceParts,
pageAiCurrentSkillSourceLabel,
pageAiSkillOriginLabel,
pageAiSkillPreferenceKey,
pageAiSkillPreferenceTable,
pageAiHermesHideBuiltinPreferenceKey,
pageAiHideHermesBuiltinSkills,
pageAiReasonixMemoryEnabled,
pageAiSetReasonixMemoryEnabled,
pageAiSetHideHermesBuiltinSkills,
pageAiSkillIsBuiltin,
pageAiToggleableSkillEntries,
pageAiAllSkillEntries,
pageAiSkillGroupCollapsed,
pageAiToggleSkillGroup,
pageAiSetSkillPreference,
pageAiSkillEnabled
};
}
@@ -1,773 +0,0 @@
export function createSidebarPageAiTargetRuntime(context) {
const {
currentDocumentId,
currentRootUri,
currentSourceKind,
currentPageOptions,
documentRef,
escapeHtml,
pageAiEnsureContextRefState,
pageUiState,
resolveWorkspaceId,
searchText,
pageAiNormalizeArray,
} = context;
function pageAiCloneJson(value) {
if (value == null) return null;
try {
return JSON.parse(JSON.stringify(value));
} catch (_) {
return null;
}
}
function localMarkdownRelativePathFromPageAiDocumentId(documentId) {
var value = String(documentId || '').trim();
if (!value.startsWith('local-md:')) return '';
return value.slice('local-md:'.length).replace(/~2F/g, '/');
}
function localMarkdownDocumentIdFromPageAiRelativePath(relativePath) {
var normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized) return '';
return 'local-md:' + normalized.split('/').map(function(segment) {
return encodeURIComponent(segment).replace(/%20/g, '~20');
}).join('~2F');
}
function pageAiWorkspacePathForDocument(documentId, seed) {
var relativePath = String(seed && seed.relativePath || localMarkdownRelativePathFromPageAiDocumentId(documentId) || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
var resolvedDocumentId = String(documentId || seed && seed.documentId || '').trim()
|| localMarkdownDocumentIdFromPageAiRelativePath(relativePath);
return {
schema: 'mnote.workspace_path.v1',
workspaceId: String(seed && seed.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim(),
sourceKind: String(seed && seed.sourceKind || currentSourceKind() || '').trim(),
rootUri: String(seed && seed.rootUri || currentRootUri() || '').trim(),
relativePath: relativePath,
documentId: resolvedDocumentId,
objectIdentity: seed && seed.objectIdentity && typeof seed.objectIdentity === 'object'
? seed.objectIdentity
: String(seed && seed.objectIdentity || resolvedDocumentId || '').trim(),
assetId: String(seed && seed.assetId || '').trim(),
resourceKind: String(seed && seed.resourceKind || 'markdown_page').trim()
};
}
function pageAiResourceKindForTarget(entry) {
var kind = String(entry && (entry.editorKind || entry.kind) || '').trim().toLowerCase();
var assetId = String(entry && entry.assetId || '').trim();
var path = String(entry && entry.path || '').trim().toLowerCase();
var workspacePath = entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
var workspaceResourceKind = String(workspacePath.resourceKind || '').trim().toLowerCase();
var officeOpenMode = String(entry && entry.officeOpenMode || '').trim().toLowerCase();
var onlyofficeSessionId = String(entry && (entry.onlyofficeSessionId || entry.bridgeSessionId) || '').trim();
if (kind === 'page' || kind === 'markdown_page') return 'markdown_page';
if (kind === 'mindmap' || path.endsWith('.mindmap.json')) return 'mindmap';
if (kind === 'office' || kind === 'onlyoffice' || kind === 'only_office' || workspaceResourceKind === 'only_office' || /\.(doc|docx|ppt|pptx|xls|xlsx)$/.test(path)) {
return officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment';
}
if (kind === 'resource' && assetId) return 'resource';
return kind || 'markdown_page';
}
function pageAiIsOnlyOfficeLiveTarget(editorTarget) {
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim().toLowerCase();
var officeOpenMode = String(editorTarget && editorTarget.officeOpenMode || '').trim().toLowerCase();
return resourceKind === 'only_office' || resourceKind === 'onlyoffice' || officeOpenMode === 'onlyoffice_live';
}
function pageAiTargetId(entry) {
if (!entry || typeof entry !== 'object') return '';
var workspacePath = entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
var entryIdentity = typeof entry.objectIdentity === 'string' ? entry.objectIdentity : '';
var workspaceIdentity = typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : '';
return String(entryIdentity || workspaceIdentity || entry.documentId || entry.assetId || entry.path || '').trim();
}
function pageAiWorkspacePathForTarget(entry) {
var seed = Object.assign({}, entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {});
var resourceKind = pageAiResourceKindForTarget(entry);
if (!seed.relativePath && entry && entry.path) seed.relativePath = entry.path;
if (!seed.assetId && entry && entry.assetId) seed.assetId = entry.assetId;
var seedResourceKind = String(seed.resourceKind || '').trim();
if (!seedResourceKind || seedResourceKind === 'page' || seedResourceKind === 'office') seed.resourceKind = resourceKind;
if (!seed.objectIdentity && entry && entry.objectIdentity) seed.objectIdentity = entry.objectIdentity;
if (!seed.workspaceId && entry && entry.workspaceId) seed.workspaceId = entry.workspaceId;
return pageAiWorkspacePathForDocument(entry && entry.documentId || currentDocumentId(), seed);
}
function pageAiTargetFromOpenEditor(entry, source) {
if (!entry || typeof entry !== 'object') return null;
var workspacePath = pageAiWorkspacePathForTarget(entry);
var targetId = pageAiTargetId(entry) || workspacePath.objectIdentity || workspacePath.documentId;
if (!targetId) return null;
var objectIdentity = typeof entry.objectIdentity === 'string'
? entry.objectIdentity
: (typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : targetId);
return {
schema: 'mnote.ai_editor_target.v1',
source: source || 'open_editors_snapshot',
targetId: targetId,
objectIdentity: objectIdentity,
workspacePath: workspacePath,
paneRole: entry.paneRole || 'primary',
documentId: entry.documentId || workspacePath.documentId,
workspaceId: entry.workspaceId || workspacePath.workspaceId || resolveWorkspaceId(documentRef.body),
editorKind: entry.editorKind || entry.kind || workspacePath.resourceKind,
resourceKind: workspacePath.resourceKind,
title: entry.title || '',
active: entry.active === true,
dirtyState: entry.dirtyState || '',
preview: entry.preview === true,
pinned: entry.pinned === true,
lastActiveAt: entry.lastActiveAt || 0,
assetId: entry.assetId || workspacePath.assetId || '',
path: entry.path || workspacePath.relativePath || '',
officeOpenMode: entry.officeOpenMode || '',
onlyofficeSessionId: entry.onlyofficeSessionId || entry.bridgeSessionId || '',
bridgeSessionId: entry.bridgeSessionId || entry.onlyofficeSessionId || '',
bridgeSessionReady: entry.bridgeSessionReady === true
};
}
function normalizeOpenEditorEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
return {
objectIdentity: String(entry.objectIdentity || '').trim(),
workspacePath: entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : null,
paneRole: String(entry.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary',
documentId: String(entry.documentId || '').trim(),
workspaceId: String(entry.workspaceId || '').trim(),
title: String(entry.title || '').trim(),
kind: String(entry.kind || entry.editorKind || '').trim(),
editorKind: String(entry.editorKind || entry.kind || '').trim(),
active: entry.active === true,
dirtyState: String(entry.dirtyState || entry.dirtyGuard || '').trim(),
preview: entry.preview === true,
pinned: entry.pinned === true,
lastActiveAt: Number(entry.lastActiveAt || 0) || 0,
assetId: String(entry.assetId || '').trim(),
path: String(entry.path || '').trim(),
officeOpenMode: String(entry.officeOpenMode || '').trim(),
onlyofficeSessionId: String(entry.onlyofficeSessionId || entry.bridgeSessionId || '').trim(),
bridgeSessionId: String(entry.bridgeSessionId || entry.onlyofficeSessionId || '').trim(),
bridgeSessionReady: entry.bridgeSessionReady === true
};
}
function currentPageAiOpenEditorsSnapshot() {
var snapshot = null;
try {
if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === 'function') {
snapshot = window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot();
}
} catch (_) {}
if (!snapshot || typeof snapshot !== 'object') snapshot = window.__mnoteOpenEditorsSnapshot || null;
if (!snapshot || typeof snapshot !== 'object') return null;
var editors = Array.isArray(snapshot.editors)
? snapshot.editors.map(normalizeOpenEditorEntry).filter(Boolean)
: [];
var resources = Array.isArray(snapshot.resourceEditors)
? snapshot.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean)
: editors.filter(function(entry) { return entry.kind !== 'page'; });
var normalizeGroup = function(group, paneRole) {
var groupEditors = group && Array.isArray(group.editors)
? group.editors.map(normalizeOpenEditorEntry).filter(Boolean)
: editors.filter(function(entry) { return entry.paneRole === paneRole; });
var groupResources = group && Array.isArray(group.resourceEditors)
? group.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean)
: resources.filter(function(entry) { return entry.paneRole === paneRole; });
var groupActive = groupEditors.find(function(entry) { return entry.active; }) || null;
return {
paneRole: paneRole,
activeObjectIdentity: String(group && group.activeObjectIdentity || (groupActive ? groupActive.objectIdentity : '') || '').trim(),
editors: groupEditors,
resourceEditors: groupResources
};
};
var groups = {
primary: normalizeGroup(snapshot.groups && snapshot.groups.primary, 'primary'),
secondary: normalizeGroup(snapshot.groups && snapshot.groups.secondary, 'secondary')
};
var activeObjectIdentity = String(snapshot.activeObjectIdentity || '').trim();
var allTargets = editors.concat(resources);
var activeEditor = allTargets.find(function(entry) {
return entry.active && (!activeObjectIdentity || entry.objectIdentity === activeObjectIdentity);
}) || groups.primary.editors.find(function(entry) {
return entry.active;
}) || groups.primary.resourceEditors.find(function(entry) {
return entry.active;
}) || groups.secondary.editors.find(function(entry) {
return entry.active;
}) || groups.secondary.resourceEditors.find(function(entry) {
return entry.active;
}) || null;
return {
schema: String(snapshot.schema || 'mnote.open_editors_snapshot.v1'),
generatedAt: Number(snapshot.generatedAt || 0) || Date.now(),
activeObjectIdentity: activeObjectIdentity || (activeEditor ? activeEditor.objectIdentity : ''),
activeEditor: activeEditor,
editors: editors,
resourceEditors: resources,
groups: groups
};
}
function pageAiFallbackEditorTarget() {
var fallbackDocumentId = currentDocumentId();
var fallbackWorkspacePath = pageAiWorkspacePathForDocument(fallbackDocumentId, null);
var fallbackTargetId = fallbackWorkspacePath.objectIdentity || fallbackDocumentId || '';
return {
schema: 'mnote.ai_editor_target.v1',
source: 'fallback_current_document',
targetId: fallbackTargetId,
objectIdentity: fallbackTargetId,
workspacePath: fallbackWorkspacePath,
paneRole: 'primary',
documentId: fallbackDocumentId,
workspaceId: resolveWorkspaceId(documentRef.body),
editorKind: 'page',
active: true,
dirtyState: '',
preview: false,
pinned: true,
lastActiveAt: Date.now(),
assetId: '',
path: ''
};
}
function pageAiEditorTargetCandidates() {
var snapshot = currentPageAiOpenEditorsSnapshot();
var entries = [];
if (snapshot) {
entries = pageAiNormalizeArray(snapshot.editors).concat(pageAiNormalizeArray(snapshot.resourceEditors));
}
var seen = {};
var targets = entries.map(function(entry) {
return pageAiTargetFromOpenEditor(entry, 'open_editors_snapshot');
}).filter(function(target) {
var id = String(target && target.targetId || '').trim();
if (!id || seen[id]) return false;
seen[id] = true;
return true;
});
if (!targets.length) targets.push(pageAiFallbackEditorTarget());
return targets;
}
function currentPageAiEditorTarget() {
var targets = pageAiEditorTargetCandidates();
var selectedId = String(pageUiState.pageAiSelectedTargetId || '').trim();
var selected = selectedId ? targets.find(function(target) { return target.targetId === selectedId; }) : null;
return selected
|| targets.find(function(target) { return target.active === true; })
|| targets[0]
|| pageAiFallbackEditorTarget();
}
function currentPageAiPageEditorTarget() {
var snapshot = currentPageAiOpenEditorsSnapshot();
var documentId = currentDocumentId();
var workspaceId = resolveWorkspaceId(documentRef.body);
var sourceKind = currentSourceKind();
var rootUri = currentRootUri();
var relativePath = localMarkdownRelativePathFromPageAiDocumentId(documentId);
var fallbackWorkspacePath = pageAiWorkspacePathForDocument(documentId, {
relativePath: relativePath
});
var pageEditor = snapshot && Array.isArray(snapshot.editors)
? snapshot.editors.find(function(entry) {
return entry
&& String(entry.editorKind || entry.kind || '') === 'page'
&& String(entry.documentId || '').trim() === String(documentId || '').trim();
})
: null;
var workspacePath = pageEditor && pageEditor.workspacePath
? Object.assign({}, pageEditor.workspacePath, {
workspaceId: workspaceId,
sourceKind: sourceKind,
rootUri: rootUri,
relativePath: relativePath,
documentId: documentId,
resourceKind: pageEditor.workspacePath.resourceKind || 'markdown_page',
objectIdentity: pageEditor.workspacePath.objectIdentity || fallbackWorkspacePath.objectIdentity
})
: fallbackWorkspacePath;
var objectIdentity = String(pageEditor && pageEditor.objectIdentity || workspacePath.objectIdentity || documentId || '').trim();
return {
schema: 'mnote.ai_editor_target.v1',
source: pageEditor ? 'current_page_from_open_editors_snapshot' : 'current_page_fallback',
targetId: objectIdentity,
objectIdentity: objectIdentity,
workspacePath: Object.assign({}, workspacePath, { objectIdentity: objectIdentity }),
paneRole: String(pageEditor && pageEditor.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary',
documentId: documentId,
workspaceId: workspaceId,
editorKind: 'page',
active: pageEditor ? pageEditor.active === true : true,
dirtyState: String(pageEditor && pageEditor.dirtyState || '').trim(),
preview: pageEditor ? pageEditor.preview === true : false,
pinned: true,
lastActiveAt: Number(pageEditor && pageEditor.lastActiveAt || 0) || Date.now(),
assetId: '',
path: relativePath
};
}
function currentPageAiScopedEditorTarget() {
var selected = pageAiEnsureContextRefState();
return selected.active_editor ? currentPageAiEditorTarget() : currentPageAiPageEditorTarget();
}
function pageAiSetRunTargetSnapshot(snapshot) {
pageUiState.pageAiCurrentRunTargetSnapshot = snapshot || null;
var target = snapshot && snapshot.editorTarget ? snapshot.editorTarget : null;
var documentId = String(target && target.documentId || snapshot && snapshot.documentId || '').trim();
var workspaceId = String(target && target.workspaceId || snapshot && snapshot.workspaceId || '').trim();
var rootUri = String(target && target.workspacePath && target.workspacePath.rootUri || snapshot && snapshot.rootUri || '').trim();
if (documentId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-document-id', documentId);
if (workspaceId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-workspace-id', workspaceId);
if (rootUri) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-root-uri', rootUri);
}
function assertPageAiTargetInCurrentWorkspace(editorTarget) {
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
var targetSourceKind = String(workspacePath.sourceKind || '').trim();
var currentKind = String(currentSourceKind() || '').trim();
if (targetSourceKind && currentKind && targetSourceKind !== currentKind) {
var sourceError = new Error('AI target 与当前页面 sourceKind 不一致,请重新选择当前工作区内的目标。');
sourceError.code = 'page_ai_target_workspace_mismatch';
throw sourceError;
}
var targetRootUri = String(workspacePath.rootUri || '').trim();
var currentRoot = String(currentRootUri() || '').trim();
if (targetRootUri && currentRoot && targetRootUri !== currentRoot) {
var rootError = new Error('AI target 与当前本地工作区 rootUri 不一致,请重新选择当前工作区内的目标。');
rootError.code = 'page_ai_target_workspace_mismatch';
throw rootError;
}
var targetWorkspaceId = String(target.workspaceId || workspacePath.workspaceId || '').trim();
var currentWorkspaceId = String(resolveWorkspaceId(documentRef.body) || '').trim();
if (targetWorkspaceId && currentWorkspaceId && targetWorkspaceId !== currentWorkspaceId) {
var workspaceError = new Error('AI target 与当前 workspaceId 不一致,请重新选择当前工作区内的目标。');
workspaceError.code = 'page_ai_target_workspace_mismatch';
throw workspaceError;
}
}
function pageAiBuildContextRefs(scopedContext, runTargetSnapshot) {
var selected = pageAiEnsureContextRefState();
var refs = [];
var documentId = currentDocumentId();
var rootUri = currentRootUri();
var workspaceId = resolveWorkspaceId(documentRef.body);
var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
if (selected.current_page) {
refs.push({
kind: 'current_page',
documentId: documentId,
rootUri: rootUri,
workspaceId: workspaceId
});
}
if (selected.selection && scopedContext && scopedContext.selectedText) {
refs.push({
kind: 'selection',
documentId: documentId,
rootUri: rootUri,
selectedBlockId: scopedContext.selectedBlockId || ''
});
}
if (selected.active_editor && editorTarget) {
var workspacePath = editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
refs.push({
kind: 'active_editor',
documentId: editorTarget.documentId || documentId,
workspaceId: editorTarget.workspaceId || workspacePath.workspaceId || workspaceId,
rootUri: workspacePath.rootUri || rootUri,
relativePath: workspacePath.relativePath || '',
editorKind: editorTarget.editorKind || '',
resourceKind: editorTarget.resourceKind || workspacePath.resourceKind || '',
targetId: editorTarget.targetId || workspacePath.objectIdentity || '',
objectIdentity: editorTarget.objectIdentity || workspacePath.objectIdentity || '',
assetId: editorTarget.assetId || workspacePath.assetId || '',
onlyofficeSessionId: editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId || '',
bridgeSessionId: editorTarget.bridgeSessionId || editorTarget.onlyofficeSessionId || ''
});
}
if (selected.file) {
refs.push({
kind: 'file',
documentId: documentId,
rootUri: rootUri,
relativePath: localMarkdownRelativePathFromPageAiDocumentId(documentId)
});
}
if (selected.folder) {
refs.push({
kind: 'folder',
rootUri: rootUri,
relativePath: ''
});
}
if (selected.changed_files) {
refs.push({
kind: 'changed_files',
rootUri: rootUri,
sinceRunTargetSnapshot: runTargetSnapshot && runTargetSnapshot.frozenAt || null
});
}
return refs.filter(function(ref) {
return ref && String(ref.kind || '').trim();
});
}
function pageAiBuildAllowedRoots() {
return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) {
return {
rootUri: root.rootUri,
permission: root.permission === 'write' || root.permission === 'read_write' ? 'write' : 'read',
recursive: root.recursive !== false,
source: root.source === 'auto' || root.source === 'user' || root.source === 'admin'
? 'sqlite_directory_grant'
: (root.source || 'sqlite_directory_grant'),
grantId: root.id || ''
};
});
}
function pageAiBuildRunTargetSnapshot(scopedContext, prompt) {
var aiContext = scopedContext && scopedContext.pageContext && scopedContext.pageContext.aiContext
? scopedContext.pageContext.aiContext
: {};
var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
return {
schema: 'mnote.page_ai_run_target_snapshot.v1',
source: 'open_editors_snapshot',
frozenAt: Date.now(),
workspaceId: resolveWorkspaceId(documentRef.body),
documentId: currentDocumentId(),
sourceKind: currentSourceKind(),
rootUri: currentRootUri(),
contextScope: pageUiState.pageAiContextScope || 'page',
promptPreview: searchText(prompt || '').slice(0, 160),
editorTarget: pageAiCloneJson(editorTarget),
activeEditorTarget: pageAiCloneJson(aiContext.activeEditorTarget || editorTarget),
openEditorsSnapshot: pageAiCloneJson(aiContext.openEditorsSnapshot || currentPageAiOpenEditorsSnapshot())
};
}
function pageAiContextKindsFromRefs(contextRefs) {
var kinds = {};
pageAiNormalizeArray(contextRefs).forEach(function(ref) {
var kind = String(ref && ref.kind || '').trim();
if (kind) kinds[kind] = true;
});
return kinds;
}
function pageAiPageContextForRefs(pageContext, contextRefs) {
var cloned = pageAiCloneJson(pageContext) || {};
var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {};
var kinds = pageAiContextKindsFromRefs(contextRefs);
delete cloned.documentBlocks;
delete cloned.evidence;
delete aiContext.contextBlocks;
delete aiContext.pageText;
delete aiContext.pageXml;
delete aiContext.truncated;
delete aiContext.warnings;
if (!kinds.selection) {
delete aiContext.selectedText;
delete aiContext.selectedBlockIds;
delete aiContext.selectedBlocks;
delete aiContext.allowedTargetBlockIds;
}
cloned.aiContext = aiContext;
return cloned;
}
function pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot) {
var target = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object'
? pageAiWorkspacePathForDocument(target.documentId || currentDocumentId(), target.workspacePath)
: pageAiWorkspacePathForDocument(target && target.documentId || currentDocumentId(), null);
var relativePath = String(workspacePath.relativePath || '').trim();
var allowedFiles = relativePath ? [relativePath] : [];
var writable = pageAiBuildAllowedRoots().some(function(root) {
return String(root && root.rootUri || '').trim() === String(workspacePath.rootUri || '').trim()
&& String(root && root.permission || '').trim() === 'write';
});
var primaryTargetId = String(target && target.targetId || workspacePath.objectIdentity || workspacePath.documentId || '').trim();
var onlyofficeSessionId = String(target && (target.onlyofficeSessionId || target.bridgeSessionId) || '').trim();
var targetEntry = {
targetId: primaryTargetId,
objectIdentity: primaryTargetId,
documentId: workspacePath.documentId,
workspaceId: workspacePath.workspaceId,
sourceKind: workspacePath.sourceKind,
rootUri: workspacePath.rootUri,
relativePath: relativePath,
resourceKind: workspacePath.resourceKind,
assetId: workspacePath.assetId || target && target.assetId || '',
onlyofficeSessionId: onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
paneRole: target && target.paneRole || 'primary',
title: target && target.title || '',
policy: {
permission: allowedFiles.length && writable ? 'read_write' : 'read',
writeRequiresCleanBuffer: true,
conflictPolicy: 'fail_on_dirty_or_stale'
}
};
return {
schema: 'mnote.agent_target_package.v1',
source: 'page_ai_run_target_snapshot',
frozenAt: runTargetSnapshot && runTargetSnapshot.frozenAt || Date.now(),
primaryTargetId: primaryTargetId,
onlyofficeSessionId: onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
workspaceId: workspacePath.workspaceId,
sourceKind: workspacePath.sourceKind,
rootUri: workspacePath.rootUri,
documentId: workspacePath.documentId,
objectIdentity: primaryTargetId,
resourceKind: workspacePath.resourceKind,
workspacePath: workspacePath,
currentFile: relativePath ? {
rootUri: workspacePath.rootUri,
relativePath: relativePath,
documentId: workspacePath.documentId,
objectIdentity: primaryTargetId,
resourceKind: workspacePath.resourceKind
} : null,
allowedFiles: allowedFiles,
targets: [targetEntry],
policy: {
writeRequiresExplicitTarget: true,
allowedFilesSource: 'selected_page_ai_target',
conflictPolicy: 'fail_on_dirty_or_stale'
}
};
}
function pageAiBlockingDirtyState(dirtyState) {
var state = String(dirtyState || '').trim();
var normalized = state.toLowerCase();
if (normalized === 'dirty') return 'Dirty';
if (normalized === 'stale') return 'Stale';
if (normalized === 'deleted') return 'Deleted';
if (normalized === 'externalmodified' || normalized === 'external-change-conflict' || normalized === 'hasexternalconflict') return 'ExternalModified';
return '';
}
async function fetchPageAiTargetBufferState(editorTarget) {
if (currentSourceKind() !== 'local_folder') return null;
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim();
if (resourceKind === 'office' || resourceKind === 'only_office' || resourceKind === 'onlyoffice') return null;
var documentId = String(target.documentId || currentDocumentId() || '').trim();
var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim();
if (!documentId || !rootUri) return null;
var relativePath = String(workspacePath.relativePath || '').trim()
|| localMarkdownRelativePathFromPageAiDocumentId(documentId);
var url = new URL('/api/documents/buffer-state', window.location.origin);
url.searchParams.set('documentId', documentId);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
var workspaceId = String(target.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
if (relativePath) url.searchParams.set('relativePath', relativePath);
try {
var response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || payload.ok !== true) return null;
return payload.result || null;
} catch (_) {
return null;
}
}
async function assertPageAiTargetWritable(editorTarget) {
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim();
var onlyofficeSessionId = String(editorTarget && (editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId) || '').trim();
if (pageAiIsOnlyOfficeLiveTarget(editorTarget) && !onlyofficeSessionId) {
var sessionError = new Error('ONLYOFFICE 资源仍在连接 MNote bridge,请等待 Office 页面加载完成后再让 AI 操作。');
sessionError.code = 'page_ai_onlyoffice_bridge_not_ready';
throw sessionError;
}
var snapshotState = pageAiBlockingDirtyState(editorTarget && editorTarget.dirtyState);
var bufferState = await fetchPageAiTargetBufferState(editorTarget);
var bufferDirtyState = pageAiBlockingDirtyState(bufferState && bufferState.dirtyState);
var blockedState = bufferDirtyState || snapshotState;
if (!blockedState) return bufferState;
var documentId = String(editorTarget && editorTarget.documentId || currentDocumentId() || '').trim();
var error = new Error('目标文档存在未保存或外部变更状态(' + blockedState + '),请先保存、解决冲突或刷新后再让 AI 写入。');
error.code = 'page_ai_target_buffer_not_writable';
error.documentId = documentId;
error.dirtyState = blockedState;
throw error;
}
function currentPageAiSelectedText() {
try {
var selection = window.getSelection ? window.getSelection() : null;
return selection ? searchText(selection.toString() || '') : '';
} catch (_) {
return '';
}
}
function pageAiProjectionBlocks(aggregate) {
var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks;
return Array.isArray(blocks) ? blocks : [];
}
function pageAiBlockText(block) {
return searchText(block && (block.text || block.title || block.content) || '');
}
function pageAiSelectedBlockIdsFromSelection() {
try {
var selection = window.getSelection ? window.getSelection() : null;
if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return [];
var range = selection.getRangeAt(0);
var editor = documentRef.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) return [];
return Array.from(editor.children).filter(function(node) {
if (!(node instanceof HTMLElement)) return false;
try {
return range.intersectsNode(node);
} catch (_) {
return false;
}
}).map(function(node) {
return searchText(node.getAttribute('data-id') || node.id || '');
}).filter(Boolean);
} catch (_) {
return [];
}
}
function pageAiBlocksToPageXml(blocks, aggregate) {
var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : '';
var pageId = currentDocumentId() || 'current-page';
var lines = ['<page id="' + escapeHtml(pageId) + '" revision="' + escapeHtml(revision) + '">'];
blocks.forEach(function(block) {
var blockId = String(block && (block.blockId || block.id) || '');
var type = String(block && block.type || 'paragraph');
var revisionRef = String(block && block.revisionRef || '');
var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : '';
lines.push(' <block id="' + escapeHtml(blockId) + '" type="' + escapeHtml(type) + '" revisionRef="' + escapeHtml(revisionRef) + '"' + level + '>' + escapeHtml(pageAiBlockText(block)) + '</block>');
});
lines.push('</page>');
return lines.join('\n');
}
function buildPageAiContext(contextSnapshot, scope, selectedText) {
var aggregate = contextSnapshot.aggregate || {};
var body = aggregate.body || {};
var allBlocks = pageAiProjectionBlocks(aggregate);
var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : [];
var selectedSet = {};
selectedBlockIds.forEach(function(id) { selectedSet[id] = true; });
var selectedBlocks = selectedBlockIds.length
? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; })
: [];
var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120);
var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length;
return {
schema: 'mnote.page_ai_context.v1',
workspaceId: resolveWorkspaceId(documentRef.body),
documentId: currentDocumentId(),
activeEditorTarget: currentPageAiScopedEditorTarget(),
openEditorsSnapshot: currentPageAiOpenEditorsSnapshot(),
scope: scope,
revision: body.revision || null,
conflictDetectionKey: body.conflictDetectionKey || null,
selectedText: selectedText || '',
selectedBlockIds: selectedBlockIds,
allowedTargetBlockIds: selectedBlockIds,
selectedBlocks: selectedBlocks,
contextBlocks: contextBlocks,
pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'),
pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate),
truncated: truncated,
warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : []
};
}
function pageAiScopedPageContext(contextSnapshot) {
var aggregate = contextSnapshot.aggregate || {};
var body = contextSnapshot.body || {};
var subtree = contextSnapshot.subtree || null;
var outline = subtree && subtree.outline ? subtree.outline : null;
var scope = pageUiState.pageAiContextScope || 'page';
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
var selectedText = scope === 'selection' ? currentPageAiSelectedText() : '';
var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText);
var editorTarget = aiContext.activeEditorTarget || currentPageAiScopedEditorTarget();
return {
pageContext: {
contextScope: scope,
documentBlocks: null,
node: {
documentId: currentDocumentId(),
title: title
},
subtree: null,
outline: null,
pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope,
evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null,
pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null,
contentAccess: 'mnote.context.read_current_page',
aiContext: aiContext
},
editorTarget: editorTarget,
selectedText: selectedText,
selectedBlockId: aiContext.selectedBlockIds[0] || null
};
}
return {
currentPageAiSelectedText,
currentPageAiEditorTarget,
currentPageAiPageEditorTarget,
currentPageAiScopedEditorTarget,
currentPageAiOpenEditorsSnapshot,
pageAiBlockText,
pageAiBlockingDirtyState,
pageAiBlocksToPageXml,
pageAiBuildAgentTargetPackage,
pageAiBuildAllowedRoots,
pageAiBuildContextRefs,
pageAiBuildRunTargetSnapshot,
pageAiCloneJson,
pageAiContextKindsFromRefs,
pageAiEditorTargetCandidates,
pageAiFallbackEditorTarget,
pageAiPageContextForRefs,
pageAiProjectionBlocks,
pageAiResourceKindForTarget,
pageAiSelectedBlockIdsFromSelection,
pageAiSetRunTargetSnapshot,
pageAiScopedPageContext,
pageAiTargetFromOpenEditor,
pageAiTargetId,
pageAiWorkspacePathForDocument,
pageAiWorkspacePathForTarget,
assertPageAiTargetInCurrentWorkspace,
assertPageAiTargetWritable,
buildPageAiContext,
fetchPageAiTargetBufferState,
localMarkdownDocumentIdFromPageAiRelativePath,
localMarkdownRelativePathFromPageAiDocumentId,
};
}
@@ -599,7 +599,6 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-extensions-subtabs" role="tablist" aria-label="扩展子面板">' +
'<button type="button" class="mnote-extensions-subtab is-active" role="tab" aria-selected="true" data-extensions-subtab="knowledge">知识库</button>' +
'<button type="button" class="mnote-extensions-subtab" role="tab" aria-selected="false" data-extensions-subtab="skills">Skills</button>' +
'<button type="button" class="mnote-extensions-subtab" role="tab" aria-selected="false" data-extensions-subtab="mcp">MCP</button>' +
'<button type="button" class="mnote-extensions-subtab" role="tab" aria-selected="false" data-extensions-subtab="tools">Agent 工具</button>' +
'</div>' +
'<div class="mnote-extensions-subpanel" data-extensions-panel="knowledge">' +
@@ -608,9 +607,6 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-extensions-subpanel" data-extensions-panel="skills" hidden>' +
'<div class="mnote-extensions-status" data-extensions-status="skills"><span class="mnote-extensions-loading">加载中...</span></div>' +
'</div>' +
'<div class="mnote-extensions-subpanel" data-extensions-panel="mcp" hidden>' +
'<div class="mnote-extensions-status" data-extensions-status="mcp"><span class="mnote-extensions-loading">加载中...</span></div>' +
'</div>' +
'<div class="mnote-extensions-subpanel" data-extensions-panel="tools" hidden>' +
'<div class="mnote-extensions-status" data-extensions-status="tools"><span class="mnote-extensions-loading">加载中...</span></div>' +
'</div>' +
@@ -625,17 +621,12 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-dashboard-card" data-dashboard-card="skills">' +
'<div class="mnote-dashboard-card-title">Skills</div>' +
'<div class="mnote-dashboard-card-value" data-dashboard-value="skills">--</div>' +
'<div class="mnote-dashboard-card-desc">已安装 / 可用</div>' +
'<div class="mnote-dashboard-card-desc">工具清单中的 skill 能力</div>' +
'</div>' +
'<div class="mnote-dashboard-card" data-dashboard-card="mcp">' +
'<div class="mnote-dashboard-card-title">MCP</div>' +
'<div class="mnote-dashboard-card-value" data-dashboard-value="mcp">--</div>' +
'<div class="mnote-dashboard-card-desc">服务器 / 已连接</div>' +
'</div>' +
'<div class="mnote-dashboard-card" data-dashboard-card="agent">' +
'<div class="mnote-dashboard-card-title">Agent Runs</div>' +
'<div class="mnote-dashboard-card-value" data-dashboard-value="agent">--</div>' +
'<div class="mnote-dashboard-card-desc">最近 7 天运行次数</div>' +
'<div class="mnote-dashboard-card" data-dashboard-card="tools">' +
'<div class="mnote-dashboard-card-title">Agent 工具</div>' +
'<div class="mnote-dashboard-card-value" data-dashboard-value="tools">--</div>' +
'<div class="mnote-dashboard-card-desc">/api/mnote/tools/manifest</div>' +
'</div>' +
'</div>' +
'<button type="button" class="wolai-page-settings-index-add" style="margin-top:10px" data-dashboard-action="refresh">刷新仪表盘</button>' +
@@ -3003,10 +2994,29 @@ export function createSidebarPageSettingsRuntime(context) {
// 切换时懒加载对应面板数据
if (subtabName === 'knowledge') refreshKnowledgeExtensionsPanel();
else if (subtabName === 'skills') refreshSkillsExtensionsPanel();
else if (subtabName === 'mcp') refreshMcpExtensionsPanel();
else if (subtabName === 'tools') refreshToolsExtensionsPanel();
}
async function fetchAgentToolsManifest() {
var resp = await fetch('/api/mnote/tools/manifest');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
var tools = Array.isArray(data && data.tools) ? data.tools : [];
var capabilities = data && data.capabilities && typeof data.capabilities === 'object'
? data.capabilities
: {};
return { data: data, tools: tools, capabilities: capabilities };
}
function isSkillRelatedTool(tool) {
if (!tool || typeof tool !== 'object') return false;
var name = String(tool.name || tool.function_name || '');
if (/skill/i.test(name)) return true;
var scopes = tool.capabilityScope || tool.capabilities || [];
if (Array.isArray(scopes) && scopes.some(function(s) { return /skill/i.test(String(s)); })) return true;
return false;
}
// 当切换到扩展 tab 时刷新当前子面板
function refreshExtensionsPanel() {
var popover = ensurePageSettingsPopover();
@@ -3051,26 +3061,30 @@ export function createSidebarPageSettingsRuntime(context) {
}
}
// --- Skills 子面板 ---
// --- Skills 子面板(来自 /api/mnote/tools/manifest,非历史 agent 网关) ---
async function refreshSkillsExtensionsPanel() {
var statusEl = document.querySelector('[data-extensions-status="skills"]');
if (!statusEl) return;
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
try {
var resp = await fetch('/api/page-ai/agent-descriptors');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
var descriptors = data.descriptors || data || [];
// 从 agent descriptor 中收集 skills 信息
var pack = await fetchAgentToolsManifest();
var allSkills = [];
descriptors.forEach(function(desc) {
var caps = desc.capabilities || desc.capabilityStates || {};
if (caps.skills && Array.isArray(caps.skills)) {
caps.skills.forEach(function(s) { allSkills.push({ name: s, source: desc.name || desc.id || 'unknown' }); });
}
pack.tools.forEach(function(tool) {
if (!isSkillRelatedTool(tool)) return;
allSkills.push({
name: tool.name || tool.function_name || 'skill',
source: 'mnote-agent-tools'
});
});
var caps = pack.capabilities;
Object.keys(caps).forEach(function(key) {
if (!/skill/i.test(key)) return;
var entry = caps[key];
var label = entry && (entry.name || entry.id) ? (entry.name || entry.id) : key;
allSkills.push({ name: String(label), source: 'capability' });
});
if (!allSkills.length) {
allSkills.push({ name: 'skill-read (内置)', source: 'hermes' });
allSkills.push({ name: 'mnote.skill.read', source: 'builtin' });
}
var html = '';
allSkills.forEach(function(skill) {
@@ -3078,63 +3092,29 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">' + escapeHtml(skill.name) + '</span><span class="mnote-extensions-source">' + escapeHtml(skill.source) + '</span></div>' +
'</div>';
});
if (!html) html = '<div class="mnote-extensions-item"><span>暂无已安装 Skills</span></div>';
if (!html) html = '<div class="mnote-extensions-item"><span>暂无 Skills</span></div>';
statusEl.innerHTML = html;
} catch (err) {
statusEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">无法加载 Skills 信息</span></div>';
}
}
// --- MCP 子面板 ---
async function refreshMcpExtensionsPanel() {
var statusEl = document.querySelector('[data-extensions-status="mcp"]');
if (!statusEl) return;
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
try {
var resp = await fetch('/api/hermes/mcp/servers');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var servers = await resp.json();
var list = Array.isArray(servers) ? servers : (servers.servers || []);
var html = '';
if (list.length === 0) {
html = '<div class="mnote-extensions-item"><span>暂无已配置 MCP 服务器</span></div>';
} else {
list.forEach(function(srv) {
var name = srv.name || srv.id || '未命名';
var url = srv.url || srv.command || '';
var connected = srv.connected ? '已连接' : '未连接';
var connClass = srv.connected ? 'mnote-status-ready' : 'mnote-status-pending';
html += '<div class="mnote-extensions-item">' +
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">' + escapeHtml(name) + '</span><span class="' + connClass + '">' + connected + '</span></div>' +
(url ? '<div class="mnote-extensions-item-row"><span class="mnote-extensions-detail">' + escapeHtml(String(url)) + '</span></div>' : '') +
'</div>';
});
}
statusEl.innerHTML = html;
} catch (err) {
statusEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">无法加载 MCP 配置</span></div>';
}
}
// --- Agent 工具子面板 ---
async function refreshToolsExtensionsPanel() {
var statusEl = document.querySelector('[data-extensions-status="tools"]');
if (!statusEl) return;
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
try {
var resp = await fetch('/api/page-ai/agent-descriptors');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
var descriptors = data.descriptors || data || [];
var pack = await fetchAgentToolsManifest();
var html = '';
descriptors.forEach(function(desc) {
var tools = desc.tools || [];
var name = desc.name || desc.id || 'unknown';
if (!tools.length) return;
if (!pack.tools.length) {
html = '<div class="mnote-extensions-item"><span>暂无 Agent 工具信息</span></div>';
} else {
html += '<div class="mnote-extensions-item mnote-extensions-agent-group">' +
'<div class="mnote-extensions-agent-name">' + escapeHtml(name) + '</div>';
tools.forEach(function(tool) {
'<div class="mnote-extensions-agent-name">mnote agent tools</div>';
pack.tools.forEach(function(tool) {
var toolName = tool.name || tool.function_name || '';
if (!toolName) return;
var disabled = tool.disabled ? ' (已禁用)' : '';
html += '<div class="mnote-extensions-tool-item">' +
'<span class="mnote-extensions-tool-name">' + escapeHtml(toolName) + '</span>' +
@@ -3142,8 +3122,7 @@ export function createSidebarPageSettingsRuntime(context) {
'</div>';
});
html += '</div>';
});
if (!html) html = '<div class="mnote-extensions-item"><span>暂无 Agent 工具信息</span></div>';
}
statusEl.innerHTML = html;
} catch (err) {
statusEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">无法加载 Agent 工具信息</span></div>';
@@ -3371,14 +3350,9 @@ export function createSidebarPageSettingsRuntime(context) {
// --- 仪表盘 ---
function refreshDashboardPanel() {
var popover = ensurePageSettingsPopover();
// 知识库状态
refreshDashboardKnowledgeCard(popover);
// Skills
refreshDashboardSkillsCard(popover);
// MCP
refreshDashboardMcpCard(popover);
// Agent runs
refreshDashboardAgentCard(popover);
refreshDashboardToolsCard(popover);
}
async function refreshDashboardKnowledgeCard(popover) {
@@ -3408,49 +3382,22 @@ export function createSidebarPageSettingsRuntime(context) {
if (!el) return;
el.textContent = '加载中...';
try {
var resp = await fetch('/api/page-ai/agent-descriptors');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
var descriptors = data.descriptors || data || [];
var count = 0;
descriptors.forEach(function(d) {
var caps = d.capabilities || d.capabilityStates || {};
if (caps.skills && Array.isArray(caps.skills)) count += caps.skills.length;
});
if (!count) count = 1; // skill-read 内置
var pack = await fetchAgentToolsManifest();
var count = pack.tools.filter(isSkillRelatedTool).length;
if (!count) count = 1; // mnote.skill.read 内置
el.textContent = count + ' 个';
} catch (_) {
el.textContent = '--';
}
}
async function refreshDashboardMcpCard(popover) {
var el = popover.querySelector('[data-dashboard-value="mcp"]');
async function refreshDashboardToolsCard(popover) {
var el = popover.querySelector('[data-dashboard-value="tools"]');
if (!el) return;
el.textContent = '加载中...';
try {
var resp = await fetch('/api/hermes/mcp/servers');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var servers = await resp.json();
var list = Array.isArray(servers) ? servers : (servers.servers || []);
var connected = list.filter(function(s) { return s.connected; }).length;
el.textContent = list.length + ' / ' + connected + ' 已连接';
} catch (_) {
el.textContent = '--';
}
}
async function refreshDashboardAgentCard(popover) {
var el = popover.querySelector('[data-dashboard-value="agent"]');
if (!el) return;
el.textContent = '加载中...';
try {
var resp = await fetch('/api/hermes/client/runs?limit=100');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
var runs = Array.isArray(data) ? data : (data.runs || []);
var recentCount = runs.length;
el.textContent = recentCount + ' 次';
var pack = await fetchAgentToolsManifest();
el.textContent = pack.tools.length + ' 个';
} catch (_) {
el.textContent = '--';
}
@@ -2982,61 +2982,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
openSearchModal();
}, true);
const sidebarPageAi = createSidebarPageAiRuntime({
buildLocalFileOpenUrl,
currentDocumentId,
currentPageAggregate,
currentPageOptions,
currentRootUri,
currentSourceKind,
escapeHtml,
cssEscape,
openLocalResourceInActiveTab,
pageUiState,
resolveWorkspaceId,
searchText,
});
// Page AI 产品入口仅 Pi Labfacade 只桥接 open / trigger / no-op delegates。
const sidebarPageAi = createSidebarPageAiRuntime({ pageUiState });
const openPageAiDrawer = (...args) => sidebarPageAi.openPageAiDrawer(...args);
const closePageAiDrawer = (...args) => sidebarPageAi.closePageAiDrawer(...args);
const isPageAiDrawerOpen = (...args) => sidebarPageAi.isPageAiDrawerOpen(...args);
const ensurePageAiDrawer = (...args) => sidebarPageAi.ensurePageAiDrawer(...args);
const sendPageAiMessage = (...args) => sidebarPageAi.sendPageAiMessage(...args);
const pageAiOpenHermesSettings = (...args) => sidebarPageAi.pageAiOpenHermesSettings(...args);
const pageAiStopRun = (...args) => sidebarPageAi.pageAiStopRun(...args);
const pageAiLoadGatewayHealth = (...args) => sidebarPageAi.pageAiLoadGatewayHealth(...args);
const renderPageAiControls = (...args) => sidebarPageAi.renderPageAiControls(...args);
const renderPageAiConversation = (...args) => sidebarPageAi.renderPageAiConversation(...args);
const renderPageAiProviderButtons = (...args) => sidebarPageAi.renderPageAiProviderButtons(...args);
const renderPageAiSuggestions = (...args) => sidebarPageAi.renderPageAiSuggestions(...args);
const pageAiSaveProfileMemory = (...args) => sidebarPageAi.pageAiSaveProfileMemory(...args);
const pageAiToggleSkill = (...args) => sidebarPageAi.pageAiToggleSkill(...args);
const pageAiToggleTool = (...args) => sidebarPageAi.pageAiToggleTool(...args);
const pageAiResumeBackendSession = (...args) => sidebarPageAi.pageAiResumeBackendSession(...args);
const pageAiRenameBackendSession = (...args) => sidebarPageAi.pageAiRenameBackendSession(...args);
const pageAiDeleteBackendSession = (...args) => sidebarPageAi.pageAiDeleteBackendSession(...args);
const pageAiResolvePermission = (...args) => sidebarPageAi.pageAiResolvePermission(...args);
const pageAiOpenLocation = (...args) => sidebarPageAi.pageAiOpenLocation(...args);
const pageAiSetActiveSession = (...args) => sidebarPageAi.pageAiSetActiveSession(...args);
const pageAiStartNewSession = (...args) => sidebarPageAi.pageAiStartNewSession(...args);
const pageAiLoadSessions = (...args) => sidebarPageAi.pageAiLoadSessions(...args);
const pageAiLoadBackendSessions = (...args) => sidebarPageAi.pageAiLoadBackendSessions(...args);
const pageAiCancelQueuedRun = (...args) => sidebarPageAi.pageAiCancelQueuedRun(...args);
const pageAiSearchBackendSessions = (...args) => sidebarPageAi.pageAiSearchBackendSessions(...args);
const pageAiPersistSessions = (...args) => sidebarPageAi.pageAiPersistSessions(...args);
const pageAiLoadProfiles = (...args) => sidebarPageAi.pageAiLoadProfiles(...args);
const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args);
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
const pageAiSetSkillSource = (...args) => sidebarPageAi.pageAiSetSkillSource(...args);
const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args);
const pageAiSetReasonixMemoryEnabled = (...args) => sidebarPageAi.pageAiSetReasonixMemoryEnabled(...args);
const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args);
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
const pageAiSetAgentPopoverOpen = (...args) => sidebarPageAi.pageAiSetAgentPopoverOpen(...args);
const pageAiSetTargetPopoverOpen = (...args) => sidebarPageAi.pageAiSetTargetPopoverOpen(...args);
const pageAiSelectTarget = (...args) => sidebarPageAi.pageAiSelectTarget(...args);
const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
@@ -3374,7 +3322,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var pageAiTrigger = closestAction(e.target, '[data-mnote-action="open-page-ai"]');
if (pageAiTrigger) {
e.preventDefault();
openPageAiDrawer();
// 产品面唯一 Page AIPi Lab(不再打开 Hermes/OpenCode legacy drawer
if (window.createSidebarPageAiPiLabRuntime) {
window.createSidebarPageAiPiLabRuntime({});
}
window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, window.location.origin);
return;
}
@@ -163,12 +163,17 @@
function ensureFormUi() {
if (!state._formUi) {
state._formUi = { openAccountIds: {}, expandedSecretPanels: {} };
state._formUi = {
openAccountIds: {},
expandedSecretPanels: {},
folderPickerOpen: {},
};
}
if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {};
if (!state._formUi.expandedSecretPanels) {
state._formUi.expandedSecretPanels = {};
}
if (!state._formUi.folderPickerOpen) state._formUi.folderPickerOpen = {};
return state._formUi;
}
@@ -892,6 +897,8 @@
kind: (s && s.kind) || 'apikey',
label: (s && s.label) || '',
value: (s && s.value) || { state: 'absent' },
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
valueClear: !!(s && s.valueClear),
accountId: (s && (s.accountId || s.account_id)) || '',
};
}
@@ -924,6 +931,96 @@
return out;
}
/** 将 ISO 时间格式化为本地可读短串;无效则原样返回。 */
function formatVaultDateTime(raw) {
var s = String(raw || '').trim();
if (!s) return '';
var d = new Date(s);
if (isNaN(d.getTime())) return s;
try {
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch (_e) {
return s;
}
}
/**
* 账号级登录态展示扩展 Cookie session
* 优先账号槽位字段合成单账号时回退条目级 hasLoginSession / sessionUpdatedAt
*/
function accountSessionView(acc, item, isSyntheticSingle) {
var has =
acc && typeof acc.hasLoginSession === 'boolean'
? acc.hasLoginSession
: isSyntheticSingle && item
? !!item.hasLoginSession
: false;
var savedAt =
(acc && (acc.lastLoginAt || acc.sessionUpdatedAt)) ||
(isSyntheticSingle && item
? item.lastLoginAt || item.sessionUpdatedAt || ''
: '') ||
'';
var expiresAt =
(acc && acc.sessionExpiresAt) ||
(isSyntheticSingle && item ? item.sessionExpiresAt || '' : '') ||
'';
return {
hasLoginSession: !!has,
savedAt: String(savedAt || '').trim(),
expiresAt: String(expiresAt || '').trim(),
source: (acc && acc.sessionSource) || '',
};
}
/** 详情页:账号下「是否保存登录态 + 保存日期」行。 */
function renderAccountSessionRows(sess, showEmpty) {
if (!sess.hasLoginSession && !showEmpty) {
// 未保存时也简短展示一行,避免用户以为功能缺失
return (
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session">' +
'<label>登录态</label>' +
'<div><span class="mnote-vault-session-badge is-absent">未保存</span></div>' +
'</div>'
);
}
var badge = sess.hasLoginSession
? '<span class="mnote-vault-session-badge is-saved" data-testid="vault-session-saved">已保存</span>'
: '<span class="mnote-vault-session-badge is-absent" data-testid="vault-session-absent">未保存</span>';
var dateText = sess.hasLoginSession
? formatVaultDateTime(sess.savedAt) || '—'
: '—';
var html =
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session">' +
'<label>登录态</label>' +
'<div class="mnote-vault-session-meta">' +
badge +
'</div></div>' +
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session-date">' +
'<label>保存日期</label>' +
'<div>' +
(sess.hasLoginSession
? escapeHtml(dateText)
: '<span class="mnote-vault-muted">—</span>') +
'</div></div>';
if (sess.hasLoginSession && sess.expiresAt) {
html +=
'<div class="mnote-vault-field-row mnote-vault-session-row" data-testid="vault-account-session-expires">' +
'<label>过期时间</label>' +
'<div>' +
escapeHtml(formatVaultDateTime(sess.expiresAt) || sess.expiresAt) +
'</div></div>';
}
return html;
}
function normalizeAccountsFromItem(item, forEdit) {
var topSecrets = normalizeSecretsFromItem(item);
var accounts = (item && item.accounts) || [];
@@ -941,6 +1038,25 @@
return s.accountId && s.accountId === a.id;
});
}
// 多账号时:若槽位未挂 session 元数据,首账号可回退条目级(兼容旧投影)
var hasSess =
typeof a.hasLoginSession === 'boolean'
? a.hasLoginSession
: aidx === 0
? !!(item && item.hasLoginSession)
: false;
var lastLogin =
a.lastLoginAt ||
(aidx === 0 && item ? item.lastLoginAt : null) ||
null;
var sessUpdated =
a.sessionUpdatedAt ||
(aidx === 0 && item ? item.sessionUpdatedAt : null) ||
null;
var sessExpires =
a.sessionExpiresAt ||
(aidx === 0 && item ? item.sessionExpiresAt : null) ||
null;
return {
id: a.id || '',
label: a.label || '',
@@ -952,7 +1068,14 @@
: a.email || '',
passwordHint: a.passwordHint || '',
password: a.password || { state: 'absent' },
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
passwordClear: !!a.passwordClear,
secrets: nested,
hasLoginSession: hasSess,
lastLoginAt: lastLogin,
sessionUpdatedAt: sessUpdated,
sessionExpiresAt: sessExpires,
sessionSource: a.sessionSource || '',
};
});
}
@@ -974,6 +1097,13 @@
passwordHint: (item && item.passwordHint) || '',
password: (item && item.password) || { state: 'absent' },
secrets: topSecrets,
// 合成单账号:登录态取条目级
hasLoginSession: !!(item && item.hasLoginSession),
lastLoginAt: (item && item.lastLoginAt) || null,
sessionUpdatedAt: (item && item.sessionUpdatedAt) || null,
sessionExpiresAt: (item && item.sessionExpiresAt) || null,
sessionSource: '',
_syntheticFromItem: true,
},
];
}
@@ -986,6 +1116,12 @@
passwordHint: '',
password: { state: 'absent' },
secrets: [],
hasLoginSession: !!(item && item.hasLoginSession),
lastLoginAt: (item && item.lastLoginAt) || null,
sessionUpdatedAt: (item && item.sessionUpdatedAt) || null,
sessionExpiresAt: (item && item.sessionExpiresAt) || null,
sessionSource: '',
_syntheticFromItem: true,
},
];
}
@@ -1157,6 +1293,11 @@
acc.username ||
acc.email ||
'账号 ' + (idx + 1);
var sess = accountSessionView(
acc,
item,
!!acc._syntheticFromItem || accounts.length === 1
);
var body =
fieldRow('标签名', acc.label, showEmpty) +
fieldRow('用户名', acc.username, showEmpty) +
@@ -1164,7 +1305,8 @@
secretRow('密码', 'password', acc.password, showEmpty, {
accountId: acc.id || '',
}) +
fieldRow('密码提示', acc.passwordHint, showEmpty);
fieldRow('密码提示', acc.passwordHint, showEmpty) +
renderAccountSessionRows(sess, showEmpty);
var secList = acc.secrets || [];
var nestedSecretsHtml = secList
.map(function (sec, sidx) {
@@ -1220,6 +1362,9 @@
(secList.length
? '<span class="mnote-vault-muted">密钥 ' + secList.length + '</span>'
: '') +
(sess.hasLoginSession
? '<span class="mnote-vault-session-badge is-saved is-compact" title="已保存登录态">登录态</span>'
: '') +
'</summary>' +
'<div class="mnote-vault-slot-body">' +
body +
@@ -1329,7 +1474,7 @@
);
}
/** Unique folderPath values from current list (for select / datalist). */
/** Unique folderPath values from current list (for picker / datalist). */
function collectFolderPaths() {
var set = {};
(state.items || []).forEach(function (item) {
@@ -1346,47 +1491,152 @@
});
}
/**
* Nested tree of known folderPath segments for the expand-style picker.
* @returns {{ name: string, path: string, children: Array }}
*/
function buildFolderPickerTree(paths) {
var root = { name: '', path: '', children: [] };
var byPath = { '': root };
(paths || []).forEach(function (fp) {
var parts = String(fp || '')
.split('/')
.filter(Boolean);
var acc = [];
var parent = root;
parts.forEach(function (part) {
acc.push(part);
var p = acc.join('/');
if (!byPath[p]) {
var node = { name: part, path: p, children: [] };
byPath[p] = node;
parent.children.push(node);
}
parent = byPath[p];
});
});
function sortNodes(nodes) {
nodes.sort(function (a, b) {
return a.name.localeCompare(b.name, 'zh');
});
nodes.forEach(function (n) {
if (n.children && n.children.length) sortNodes(n.children);
});
}
sortNodes(root.children);
return root;
}
/**
* Whether a picker branch starts expanded.
* Default: all collapsed. Only expand ancestors of the current selection
* (so the selected path stays visible), or branches the user toggled open.
*/
function isFolderPickerBranchOpen(path, depth, current) {
var ui = ensureFormUi();
if (!ui.folderPickerOpen) ui.folderPickerOpen = {};
if (Object.prototype.hasOwnProperty.call(ui.folderPickerOpen, path)) {
return !!ui.folderPickerOpen[path];
}
// Only auto-open ancestors of the selected path (not the leaf itself unless it has kids under selection).
if (current && path && current.indexOf(path + '/') === 0) {
return true;
}
return false;
}
function renderFolderPickerNode(node, depth, current) {
var kids = node.children || [];
var hasKids = kids.length > 0;
var selected = current === node.path;
var open = hasKids && isFolderPickerBranchOpen(node.path, depth, current);
var html = '';
html +=
'<div class="mnote-vault-folder-picker-node" data-vault-picker-path="' +
escapeHtml(node.path) +
'" style="--vault-picker-depth:' +
depth +
'">';
html += '<div class="mnote-vault-folder-picker-row' + (selected ? ' is-selected' : '') + '">';
if (hasKids) {
html +=
'<button type="button" class="mnote-vault-folder-picker-chevron" data-vault-folder-picker-toggle="' +
escapeHtml(node.path) +
'" aria-expanded="' +
(open ? 'true' : 'false') +
'" title="' +
(open ? '收起' : '展开') +
'">' +
(open ? '▼' : '▶') +
'</button>';
} else {
html += '<span class="mnote-vault-folder-picker-chevron is-leaf" aria-hidden="true"></span>';
}
html +=
'<button type="button" class="mnote-vault-folder-picker-label" data-vault-folder-pick="' +
escapeHtml(node.path) +
'" data-testid="vault-folder-pick-' +
escapeHtml(node.path || 'root') +
'">' +
escapeHtml(node.name || node.path) +
'</button>';
html += '</div>';
if (hasKids) {
html +=
'<div class="mnote-vault-folder-picker-children"' +
(open ? '' : ' hidden') +
'>';
kids.forEach(function (child) {
html += renderFolderPickerNode(child, depth + 1, current);
});
html += '</div>';
}
html += '</div>';
return html;
}
function formFolderPathField(value) {
var current = (value || '').trim();
var current = normalizeFolderPath(value || '');
var paths = collectFolderPaths();
var seen = {};
paths.forEach(function (p) {
seen[p] = true;
});
var options =
'<option value=""' +
(!current ? ' selected' : '') +
'>(无分组)</option>';
paths.forEach(function (p) {
options +=
'<option value="' +
escapeHtml(p) +
'"' +
(p === current ? ' selected' : '') +
'>' +
escapeHtml(p) +
'</option>';
});
// 当前值若不在列表中,仍挂到树上便于高亮/再选。
var treePaths = paths.slice();
if (current && !seen[current]) {
options +=
'<option value="' +
escapeHtml(current) +
'" selected>' +
escapeHtml(current) +
'</option>';
treePaths.push(current);
var parts = current.split('/').filter(Boolean);
for (var i = 1; i < parts.length; i++) {
var prefix = parts.slice(0, i).join('/');
if (treePaths.indexOf(prefix) < 0) treePaths.push(prefix);
}
}
var tree = buildFolderPickerTree(treePaths);
var treeHtml = '';
treeHtml +=
'<button type="button" class="mnote-vault-folder-picker-none' +
(!current ? ' is-selected' : '') +
'" data-vault-folder-pick="" data-testid="vault-folder-pick-none">(无分组)</button>';
tree.children.forEach(function (child) {
treeHtml += renderFolderPickerNode(child, 0, current);
});
if (!tree.children.length && !current) {
treeHtml +=
'<div class="mnote-vault-folder-picker-empty mnote-vault-muted">暂无已有分组,可在下方输入新建</div>';
}
return (
'<div class="mnote-vault-field-row mnote-vault-folder-field">' +
'<label for="vault-f-folderPath">分组</label>' +
'<div class="mnote-vault-folder-controls">' +
'<select id="vault-f-folderSelect" data-vault-folder-select data-testid="vault-folder-select" aria-label="选择分组">' +
options +
'</select>' +
'<div class="mnote-vault-folder-picker" data-vault-folder-picker data-testid="vault-folder-select" role="listbox" aria-label="选择分组">' +
treeHtml +
'</div>' +
'<input id="vault-f-folderPath" name="folderPath" type="text" ' +
'value="' +
escapeHtml(current) +
'" placeholder="新分组可直接输入用 / 分层" ' +
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" />' +
'" placeholder="点上方分组选择,或直接输入用 / 分层" ' +
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" autocomplete="off" />' +
'<datalist id="vault-folder-path-list">' +
paths
.map(function (p) {
@@ -1394,39 +1644,75 @@
})
.join('') +
'</datalist>' +
'<p class="mnote-vault-form-hint mnote-vault-folder-hint">分组默认折叠;点 ▶ 展开,点名称即可选中。也可在下方直接输入路径。</p>' +
'</div></div>'
);
}
function syncFolderPickerSelection(form, path) {
if (!form) return;
var picker = qs('[data-vault-folder-picker]', form);
if (!picker) return;
var normalized = normalizeFolderPath(path || '');
qsa('[data-vault-folder-pick]', picker).forEach(function (btn) {
var p = btn.getAttribute('data-vault-folder-pick');
if (p == null) p = '';
var row = btn.closest('.mnote-vault-folder-picker-row');
var selected = p === normalized;
btn.classList.toggle('is-selected', selected);
if (row) row.classList.toggle('is-selected', selected);
if (btn.classList.contains('mnote-vault-folder-picker-none')) {
btn.classList.toggle('is-selected', selected);
}
});
}
function bindFolderPathControls(form) {
if (!form) return;
var select = qs('[data-vault-folder-select]', form);
var input = qs('[data-vault-folder-input]', form);
if (select && input) {
select.addEventListener('change', function () {
input.value = select.value || '';
var picker = qs('[data-vault-folder-picker]', form);
if (picker) {
picker.addEventListener('click', function (ev) {
var t = ev.target;
if (!(t instanceof Element)) return;
var toggle = t.closest('[data-vault-folder-picker-toggle]');
if (toggle && picker.contains(toggle)) {
ev.preventDefault();
var tpath = toggle.getAttribute('data-vault-folder-picker-toggle') || '';
var nodeEl = toggle.closest('.mnote-vault-folder-picker-node');
// First matching descendants is the direct children panel for this node.
var kidsEl =
nodeEl && nodeEl.querySelector('.mnote-vault-folder-picker-children');
var open = toggle.getAttribute('aria-expanded') === 'true';
var next = !open;
ensureFormUi().folderPickerOpen = ensureFormUi().folderPickerOpen || {};
ensureFormUi().folderPickerOpen[tpath] = next;
toggle.setAttribute('aria-expanded', next ? 'true' : 'false');
toggle.textContent = next ? '▼' : '▶';
toggle.setAttribute('title', next ? '收起' : '展开');
if (kidsEl) kidsEl.hidden = !next;
return;
}
var pick = t.closest('[data-vault-folder-pick]');
if (pick && picker.contains(pick)) {
ev.preventDefault();
var chosen = pick.getAttribute('data-vault-folder-pick');
if (chosen == null) chosen = '';
chosen = normalizeFolderPath(chosen);
if (input) input.value = chosen;
syncFolderPickerSelection(form, chosen);
state.dirty = true;
}
});
}
if (input) {
input.addEventListener('input', function () {
var n = normalizeFolderPath(input.value);
// keep select in sync when user picks a known path via typing
if (select) {
var match = false;
for (var i = 0; i < select.options.length; i++) {
if (select.options[i].value === n) {
select.selectedIndex = i;
match = true;
break;
}
}
if (!match) select.selectedIndex = 0;
}
syncFolderPickerSelection(form, input.value);
});
input.addEventListener('change', function () {
var n = normalizeFolderPath(input.value);
input.value = n;
syncFolderPickerSelection(form, n);
state.dirty = true;
});
}
@@ -1439,24 +1725,55 @@
var urls = normalizeUrlsFromItem(item);
if (!urls.length) urls = [''];
state._formUrls = urls.slice();
/**
* Rebuild draft slots from item projection.
* reRenderFormKeepingSlots injects in-progress plaintext as
* `{ state: 'revealed', value }` must become valueText/passwordValue,
* not be wiped to '' (that made the 1st new secret disappear after +密钥).
* valueClear/passwordClear on the pseudo-item are also preserved.
*/
state._formAccounts = accounts.map(function (a) {
var pwd = a.password || { state: 'absent' };
var passwordClear = !!a.passwordClear;
var passwordValue = '';
var passwordState = pwd;
if (passwordClear) {
passwordState = { state: 'absent' };
passwordValue = '';
} else if (pwd && pwd.state === 'revealed') {
// Draft plaintext typed in this session (not yet saved / mid re-render).
passwordValue = String(pwd.value || '');
// Treat as "no stored secret yet" for keep-logic; save uses passwordValue.
passwordState = { state: 'absent' };
}
return {
id: a.id || newClientId('acc'),
label: a.label || '',
username: a.username || '',
email: a.email || '',
passwordHint: a.passwordHint || '',
passwordState: a.password || { state: 'absent' },
passwordClear: false,
passwordValue: '',
passwordState: passwordState,
passwordClear: passwordClear,
passwordValue: passwordValue,
secrets: (a.secrets || []).map(function (s) {
var v = s.value || { state: 'absent' };
var valueClear = !!s.valueClear;
var valueText = '';
var valueState = v;
if (valueClear) {
valueState = { state: 'absent' };
valueText = '';
} else if (v && v.state === 'revealed') {
valueText = String(v.value || '');
valueState = { state: 'absent' };
}
return {
id: s.id || newClientId('sec'),
kind: s.kind || 'apikey',
label: s.label || '',
valueState: s.value || { state: 'absent' },
valueClear: false,
valueText: '',
valueState: valueState,
valueClear: valueClear,
valueText: valueText,
accountId: a.id || '',
};
}),
@@ -1592,9 +1909,11 @@
'<div class="mnote-vault-secret-edit">' +
'<input type="text" autocomplete="off" spellcheck="false" data-acc-field="password" data-acc-id="' +
escapeHtml(acc.id) +
'" class="mnote-vault-secret-input-plain" placeholder="' +
'" class="mnote-vault-secret-input-plain" value="' +
escapeHtml(acc.passwordValue || '') +
'" placeholder="' +
escapeHtml(
!isCreate && hasPwd
!isCreate && hasPwd && !acc.passwordValue
? '留空不改;输入新值覆盖'
: '可选;可用 [Key] 密文片段'
) +
@@ -1602,7 +1921,11 @@
(!isCreate && hasPwd
? '<button type="button" data-acc-show-password="' +
escapeHtml(acc.id) +
'" data-testid="vault-acc-show-password">显示</button>' +
'"' +
(acc.passwordValue ? ' data-shown="1"' : '') +
' data-testid="vault-acc-show-password">' +
(acc.passwordValue ? '隐藏' : '显示') +
'</button>' +
'<button type="button" data-acc-clear-password="' +
escapeHtml(acc.id) +
'">清空</button>'
@@ -1624,6 +1947,7 @@
sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key';
var title = sec.label || kindLabel + ' ' + (idx + 1);
var hasVal = sec.valueState && sec.valueState.state === 'masked';
var draftText = sec.valueText || '';
return (
'<div class="mnote-vault-nested-secret" data-vault-secret-slot="' +
escapeHtml(sec.id) +
@@ -1672,15 +1996,21 @@
escapeHtml(sec.id) +
'" data-acc-id="' +
escapeHtml(accountId) +
'" value="" placeholder="' +
escapeHtml(hasVal ? '留空不改;输入新值覆盖' : '密钥明文') +
'" value="' +
escapeHtml(draftText) +
'" placeholder="' +
escapeHtml(hasVal && !draftText ? '留空不改;输入新值覆盖' : '密钥明文') +
'" />' +
(hasVal
? '<button type="button" data-sec-show-value="' +
escapeHtml(sec.id) +
'" data-acc-id="' +
escapeHtml(accountId) +
'" data-testid="vault-sec-show-value">显示</button>' +
'"' +
(draftText ? ' data-shown="1"' : '') +
' data-testid="vault-sec-show-value">' +
(draftText ? '隐藏' : '显示') +
'</button>' +
'<button type="button" data-sec-clear-value="' +
escapeHtml(sec.id) +
'" data-acc-id="' +
@@ -1830,6 +2160,8 @@
username: a.username,
email: a.email,
passwordHint: a.passwordHint,
// Flags survive re-render so clear intent is not lost.
passwordClear: !!a.passwordClear,
password: a.passwordClear
? { state: 'absent' }
: a.passwordValue
@@ -1841,6 +2173,7 @@
kind: s.kind,
label: s.label,
accountId: a.id,
valueClear: !!s.valueClear,
value: s.valueClear
? { state: 'absent' }
: s.valueText
@@ -2040,13 +2373,30 @@
var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim());
var accounts = (state._formAccounts || [])
.map(function (acc) {
// Prefer live input draft; fall back to revealed draft left by re-render inject.
var raw = String(acc.passwordValue || '');
if (
!raw &&
acc.passwordState &&
acc.passwordState.state === 'revealed' &&
acc.passwordState.value
) {
raw = String(acc.passwordState.value);
}
if (/^[•*·]+$/.test(raw) || raw === '••••••••') {
toast('不能将掩码写回 password', 'error');
throw new Error('mask');
}
var nestedSecrets = (acc.secrets || []).map(function (sec) {
var sraw = String(sec.valueText || '');
if (
!sraw &&
sec.valueState &&
sec.valueState.state === 'revealed' &&
sec.valueState.value
) {
sraw = String(sec.valueState.value);
}
if (/^[•*·]+$/.test(sraw) || sraw === '••••••••') {
toast('不能将掩码写回 secret', 'error');
throw new Error('mask');
-621
View File
@@ -1,621 +0,0 @@
/// ACP ↔ SSE bridge for Hermes route integration.
///
/// Transforms ACP session events into the SSE event format expected by the
/// frontend (HermesRunEvent), and manages the background prompt lifecycle.
///
/// Reference: `hermes-vscode-main/src/sessionManager.ts` handleUpdate()
use crate::acp_runtime::AcpRuntimeManager;
use crate::acp_session_manager::{AcpSessionEvent, AcpSessionManager};
use crate::acp_types::ContentBlock;
use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::Response;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::{info, warn};
/// Errors from the ACP bridge.
#[derive(Debug)]
pub enum AcpBridgeError {
NoActiveRuntime,
SessionError(String),
StreamError(String),
}
impl std::fmt::Display for AcpBridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcpBridgeError::NoActiveRuntime => write!(f, "no active ACP runtime"),
AcpBridgeError::SessionError(msg) => write!(f, "ACP session error: {msg}"),
AcpBridgeError::StreamError(msg) => write!(f, "ACP stream error: {msg}"),
}
}
}
/// SSE event types sent to the frontend.
/// Mirrors HermesRunEvent from bridge.ts.
#[derive(Debug, Clone)]
pub struct SseEvent {
pub event: String,
pub data: Value,
}
/// Bridge state for one run: holds the broadcast channel for SSE events.
pub struct AcpRunBridge {
session_id: String,
event_tx: broadcast::Sender<SseEvent>,
}
fn collect_citation_markdowns_from_value(value: &Value) -> Vec<Value> {
fn add_citation_value(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
let Some(citation) = value.get("citationMarkdown").and_then(Value::as_str) else {
return;
};
let citation = citation.trim();
if citation.is_empty() || !seen.insert(citation.to_string()) {
return;
}
out.push(json!({
"schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
"citationMarkdown": citation,
"citationId": value.get("citationId").cloned().unwrap_or(Value::Null),
"citationLabel": value.get("citationLabel").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": value.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"sourcePath": value.get("sourcePath").cloned().unwrap_or(Value::Null),
"filePath": value.get("filePath").or_else(|| value.get("lightRagFilePath")).cloned().unwrap_or(Value::Null),
"headingPath": value.get("headingPath").cloned().unwrap_or_else(|| json!([])),
"displayQuote": value.get("displayQuote").or_else(|| value.get("quote")).cloned().unwrap_or(Value::Null),
"locatorEvidenceText": value.get("locatorEvidenceText").cloned().unwrap_or(Value::Null),
"locatorPrecision": value.get("locatorPrecision").cloned().unwrap_or(Value::Null),
"locatorDegraded": value.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
"citationUrl": value.get("citationUrl").cloned().unwrap_or(Value::Null),
}));
}
fn add_reference_citations(
references: &[Value],
seen: &mut HashSet<String>,
out: &mut Vec<Value>,
) -> bool {
let has_precise = references.iter().any(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
&& reference.get("locatorDegraded").and_then(Value::as_bool) != Some(true)
});
let mut added = false;
for reference in references {
if out.len() >= 8 {
break;
}
let Some(_citation) = reference.get("citationMarkdown").and_then(Value::as_str) else {
continue;
};
if has_precise
&& reference.get("locatorDegraded").and_then(Value::as_bool) == Some(true)
{
continue;
}
let before = out.len();
add_citation_value(reference, seen, out);
added = added || out.len() > before;
}
added
}
fn visit(value: &Value, seen: &mut HashSet<String>, out: &mut Vec<Value>) {
if out.len() >= 8 {
return;
}
match value {
Value::String(text) => {
let trimmed = text.trim();
if (trimmed.starts_with('{') || trimmed.starts_with('['))
&& trimmed.contains("citationMarkdown")
{
if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
visit(&parsed, seen, out);
} else if let Some(first_line) = trimmed.lines().next() {
if let Ok(parsed) = serde_json::from_str::<Value>(first_line.trim()) {
visit(&parsed, seen, out);
}
}
}
}
Value::Array(items) => {
for item in items {
visit(item, seen, out);
if out.len() >= 8 {
break;
}
}
}
Value::Object(map) => {
let has_filtered_references = map
.get("references")
.and_then(Value::as_array)
.is_some_and(|references| add_reference_citations(references, seen, out));
if let Some(_citation) = map.get("citationMarkdown").and_then(Value::as_str) {
if !has_filtered_references {
add_citation_value(value, seen, out);
}
}
for (key, item) in map {
if has_filtered_references
&& matches!(key.as_str(), "references" | "citations" | "uiCitations")
{
continue;
}
visit(item, seen, out);
if out.len() >= 8 {
break;
}
}
}
_ => {}
}
}
let mut seen = HashSet::new();
let mut out = Vec::new();
visit(value, &mut seen, &mut out);
out
}
impl AcpRunBridge {
/// Create a new ACP run: create session + start prompt in background.
///
/// Returns a bridge with a broadcast receiver that the SSE endpoint can use.
pub async fn start(
runtime_mgr: &AcpRuntimeManager,
runtime_name: &str,
prompt_blocks: Vec<ContentBlock>,
) -> Result<Self, AcpBridgeError> {
// Get or activate the runtime
let client = if runtime_mgr.is_active().await {
runtime_mgr
.active_client()
.await
.ok_or(AcpBridgeError::NoActiveRuntime)?
} else {
runtime_mgr
.switch_to(runtime_name)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?
};
// Create session manager
let mgr = Arc::new(AcpSessionManager::new(client));
// Create event channel (256 buffered, enough for SSE streaming)
let (event_tx, _) = broadcast::channel(256);
let event_tx_clone = event_tx.clone();
// Set up event handler
mgr.on_event(move |event| {
if let Some(sse) = acp_event_to_sse(event) {
let _ = event_tx_clone.send(sse);
}
});
// Create session
let sid = mgr
.create_session(None, None)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?;
// Start prompt in background
let mgr_clone = mgr.clone();
let event_tx_prompt = event_tx.clone();
tokio::spawn(async move {
match mgr_clone.run_prompt(prompt_blocks).await {
Ok(result) => {
info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
let _ = event_tx_prompt.send(SseEvent {
event: "run.completed".into(),
data: json!({
"stopReason": format!("{:?}", result.stop_reason),
}),
});
}
Err(e) => {
warn!("ACP prompt failed: {e}");
let _ = event_tx_prompt.send(SseEvent {
event: "run.failed".into(),
data: json!({ "error": e.to_string() }),
});
}
}
});
info!("ACP run started: session={}", sid);
Ok(Self {
session_id: sid,
event_tx,
})
}
/// Cancel the current run.
pub async fn abort(&self) {
// Cancellation is sent via the session manager.
// For now, we just drop the bridge — the background task will detect this
// via the broadcast channel being closed.
info!("ACP run aborted: session={}", self.session_id);
}
/// Create an SSE response body from the event broadcast receiver.
pub fn into_sse_response(self) -> Response {
use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
let mut broadcast_rx = self.event_tx.subscribe();
// Forward events from broadcast to mpsc
tokio::spawn(async move {
loop {
match broadcast_rx.recv().await {
Ok(event) => {
let json =
serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(format!(
"event: {}\ndata: {}\n\n",
event.event, json
));
if tx.send(Ok(bytes)).await.is_err() {
break; // receiver dropped
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("ACP SSE lagged: {n} events dropped");
}
Err(broadcast::error::RecvError::Closed) => {
break; // stream ended
}
}
}
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.unwrap()
})
}
}
/// Map an AcpSessionEvent to an SSE event for the frontend.
///
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
/// Reference: recycle/wolai-frontend bridge.ts HermesRunEvent type (historic)
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
match event {
AcpSessionEvent::TextDelta { text } => Some(SseEvent {
event: "message.delta".into(),
data: json!({ "delta": text }),
}),
AcpSessionEvent::ThoughtDelta { text } => Some(SseEvent {
event: "thought.delta".into(),
data: json!({ "delta": text }),
}),
AcpSessionEvent::ToolCall {
tool_call_id,
title,
kind,
status,
raw_input,
locations,
} => Some(SseEvent {
event: "tool.started".into(),
data: json!({
"tool": title,
"toolCallId": tool_call_id,
"kind": kind,
"status": status,
"input": raw_input,
"locations": locations,
}),
}),
AcpSessionEvent::ToolCallUpdate {
tool_call_id,
status,
content,
} => {
let output = json!(content);
let citation_markdowns = collect_citation_markdowns_from_value(&output);
let error = status == crate::acp_types::ToolCallStatus::Failed;
let event = if error {
"tool.failed"
} else if status == crate::acp_types::ToolCallStatus::Completed {
"tool.completed"
} else {
"tool.started"
};
Some(SseEvent {
event: event.into(),
data: json!({
"toolCallId": tool_call_id,
"status": status,
"error": error,
"output": output,
"citationMarkdowns": citation_markdowns,
}),
})
}
AcpSessionEvent::UsageUpdate { used, size } => Some(SseEvent {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
}),
AcpSessionEvent::PermissionRequest {
permission_id,
tool_name,
params,
decision,
} => {
let event = match decision.as_str() {
"allowed" => "permission.allowed",
"requested" => "permission.requested",
_ => "permission.denied",
};
Some(SseEvent {
event: event.into(),
data: json!({
"permissionId": permission_id,
"toolName": tool_name,
"params": params,
"decision": decision,
}),
})
}
AcpSessionEvent::SessionInfoUpdate { title } => Some(SseEvent {
event: "session.info.updated".into(),
data: json!({ "title": title }),
}),
AcpSessionEvent::ProviderConversationBound {
provider,
remote_conversation_id,
remote_url,
acp_session_id,
} => Some(SseEvent {
event: "provider.conversation.bound".into(),
data: json!({
"provider": provider,
"remoteConversationId": remote_conversation_id,
"remoteUrl": remote_url,
"acpSessionId": acp_session_id,
}),
}),
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
event: "plan.updated".into(),
data: json!({ "entries": entries }),
}),
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
event: "run.failed".into(),
data: json!({ "error": reason }),
}),
}
}
/// Helper: get the runtime name from a profile.
/// For now, we use "hermes" or "reasonix" directly.
/// In Step 12, this will come from the profile config.
pub fn runtime_name_for_profile(profile: &str) -> &str {
match profile {
"reasonix" => "reasonix",
_ => "hermes",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acp_normal_session_close_does_not_emit_failure() {
let event = AcpSessionEvent::Disconnected {
reason: "session closed".into(),
};
assert!(acp_event_to_sse(event).is_none());
}
#[test]
fn acp_unexpected_disconnect_emits_failure() {
let event = AcpSessionEvent::Disconnected {
reason: "transport lost".into(),
};
let sse = acp_event_to_sse(event).expect("unexpected disconnect should be forwarded");
assert_eq!(sse.event, "run.failed");
assert_eq!(sse.data["error"], "transport lost");
}
#[test]
fn acp_thought_delta_does_not_emit_message_delta() {
let event = AcpSessionEvent::ThoughtDelta {
text: "internal reasoning".into(),
};
let sse = acp_event_to_sse(event).expect("thought delta should be forwarded separately");
assert_eq!(sse.event, "thought.delta");
assert_eq!(sse.data["delta"], "internal reasoning");
}
#[test]
fn acp_permission_request_emits_frontend_decision_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_1".into(),
tool_name: "mnote.page.save".into(),
params: json!({"documentId": "doc_1"}),
decision: "denied".into(),
};
let sse = acp_event_to_sse(event).expect("permission decision should be forwarded");
assert_eq!(sse.event, "permission.denied");
assert_eq!(sse.data["permissionId"], "perm_1");
assert_eq!(sse.data["toolName"], "mnote.page.save");
assert_eq!(sse.data["params"]["documentId"], "doc_1");
assert_eq!(sse.data["decision"], "denied");
}
#[test]
fn acp_permission_requested_emits_permission_requested_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_2".into(),
tool_name: "mnote.page.get".into(),
params: json!({"documentId": "doc_2"}),
decision: "requested".into(),
};
let sse = acp_event_to_sse(event).expect("permission requested should be forwarded");
assert_eq!(sse.event, "permission.requested");
assert_eq!(sse.data["permissionId"], "perm_2");
assert_eq!(sse.data["toolName"], "mnote.page.get");
assert_eq!(sse.data["decision"], "requested");
}
#[test]
fn acp_permission_allowed_emits_permission_allowed_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_3".into(),
tool_name: "mnote.page.save".into(),
params: json!({"documentId": "doc_1"}),
decision: "allowed".into(),
};
let sse = acp_event_to_sse(event).expect("permission allowed should be forwarded");
assert_eq!(sse.event, "permission.allowed");
assert_eq!(sse.data["permissionId"], "perm_3");
assert_eq!(sse.data["decision"], "allowed");
}
#[test]
fn acp_tool_events_keep_detail_for_collapsible_ui() {
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
tool_call_id: "tool_1".into(),
title: "mnote.page.get".into(),
kind: "read".into(),
status: crate::acp_types::ToolCallStatus::InProgress,
raw_input: Some(json!({"documentId": "doc_1", "includeBody": true})),
locations: vec!["/mnt/Data1T/mnote/src/main.rs".into()],
})
.expect("tool start");
assert_eq!(started.event, "tool.started");
assert_eq!(started.data["tool"], "mnote.page.get");
assert_eq!(started.data["status"], "in_progress");
assert_eq!(started.data["input"]["documentId"], "doc_1");
assert_eq!(
started.data["locations"][0],
"/mnt/Data1T/mnote/src/main.rs"
);
assert_eq!(started.data["locations"].as_array().unwrap().len(), 1);
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
tool_call_id: "tool_1".into(),
status: crate::acp_types::ToolCallStatus::Completed,
content: Some(vec![crate::acp_types::ContentBlockWrapper {
wrapper_type: "content".into(),
content: crate::acp_types::TextContent {
content_type: "text".into(),
text: "读取完成".into(),
},
}]),
})
.expect("tool complete");
assert_eq!(completed.event, "tool.completed");
assert_eq!(completed.data["status"], "completed");
assert_eq!(completed.data["output"][0]["content"]["text"], "读取完成");
let running = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
tool_call_id: "tool_1".into(),
status: crate::acp_types::ToolCallStatus::InProgress,
content: None,
})
.expect("tool running");
assert_eq!(running.event, "tool.started");
assert_eq!(running.data["status"], "in_progress");
}
#[test]
fn acp_tool_completed_extracts_precise_ui_citations_from_prefixed_text() {
let prefix = json!({
"schema": "mnote.acp.tool_result_ui_citations.v1",
"references": [{
"citationMarkdown": "[来源定位降级:a.md](/documents/a)",
"locatorDegraded": true
}, {
"citationMarkdown": "[b.md · p.2](/documents/b?page=2)",
"locatorDegraded": false
}],
"uiCitations": [{
"citationMarkdown": "[来源定位降级:a.md](/documents/a)"
}]
})
.to_string();
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
tool_call_id: "tool_2".into(),
status: crate::acp_types::ToolCallStatus::Completed,
content: Some(vec![crate::acp_types::ContentBlockWrapper {
wrapper_type: "content".into(),
content: crate::acp_types::TextContent {
content_type: "text".into(),
text: format!("{prefix}\n工具正文"),
},
}]),
})
.expect("tool complete");
assert_eq!(
completed.data["citationMarkdowns"][0]["citationMarkdown"].as_str(),
Some("[b.md · p.2](/documents/b?page=2)")
);
assert_eq!(
completed.data["citationMarkdowns"]
.as_array()
.unwrap()
.len(),
1
);
}
#[test]
fn acp_session_info_update_emits_session_info_updated_sse() {
let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate {
title: "我的新会话标题".into(),
})
.expect("session info update should be forwarded");
assert_eq!(sse.event, "session.info.updated");
assert_eq!(sse.data["title"], "我的新会话标题");
}
#[test]
fn acp_plan_update_emits_plan_updated_sse() {
let entries = vec![
"步骤 1:读取文件".into(),
"步骤 2:修改配置".into(),
"步骤 3:验证更改".into(),
];
let sse = acp_event_to_sse(AcpSessionEvent::PlanUpdate {
entries: entries.clone(),
})
.expect("plan update should be forwarded");
assert_eq!(sse.event, "plan.updated");
let sse_entries: Vec<String> =
serde_json::from_value(sse.data["entries"].clone()).unwrap_or_default();
assert_eq!(sse_entries.len(), 3);
assert_eq!(sse_entries[0], "步骤 1:读取文件");
assert_eq!(sse_entries[1], "步骤 2:修改配置");
assert_eq!(sse_entries[2], "步骤 3:验证更改");
}
}
-755
View File
@@ -1,755 +0,0 @@
/// ACP (Agent Client Protocol) JSON-RPC 2.0 client.
///
/// Walks an agent runtime subprocess (e.g. `hermes acp` or `node reasonix-acp-wrapper.mjs`)
/// over NDJSON stdio: one JSON object per line, newline-delimited.
///
/// Reference implementations:
/// - `reference-code/hermes-vscode-main/src/acpClient.ts` (primary reference)
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
///
/// Wire format:
/// Request: { jsonrpc: "2.0", id: number, method: string, params?: object }
/// Response: { jsonrpc: "2.0", id: number, result?: any, error?: { code, message } }
/// Notification:{ jsonrpc: "2.0", method: string, params?: object } (no id)
/// Incoming: { jsonrpc: "2.0", id: number, method: string, params?: object } (from agent)
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::{oneshot, Mutex};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
// ── Error types ──────────────────────────────────────
#[derive(Debug)]
pub enum AcpError {
Spawn(std::io::Error),
JsonParse(serde_json::Error),
JsonRpc { code: i64, message: String },
Timeout(u64),
Closed,
Internal(String),
}
impl std::fmt::Display for AcpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcpError::Spawn(e) => write!(f, "ACP spawn failed: {e}"),
AcpError::JsonParse(e) => write!(f, "ACP JSON parse error: {e}"),
AcpError::JsonRpc { code, message } => {
write!(f, "ACP JSON-RPC error [{code}]: {message}")
}
AcpError::Timeout(secs) => write!(f, "ACP request timed out after {secs}s"),
AcpError::Closed => write!(f, "ACP connection closed"),
AcpError::Internal(msg) => write!(f, "ACP internal: {msg}"),
}
}
}
impl std::error::Error for AcpError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AcpError::Spawn(e) => Some(e),
AcpError::JsonParse(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for AcpError {
fn from(e: std::io::Error) -> Self {
AcpError::Spawn(e)
}
}
impl From<serde_json::Error> for AcpError {
fn from(e: serde_json::Error) -> Self {
AcpError::JsonParse(e)
}
}
// ── Notification handler type ────────────────────────
type NotificationHandler = Box<dyn Fn(String, Value) + Send + 'static>;
/// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits).
type NotificationHandlerMutex = std::sync::Mutex<Option<NotificationHandler>>;
// ── Incoming request handler type ────────────────────
///
/// agent 发送 JSON-RPC request(同时包含 `id` 与 `method`)时调用。
/// 返回 `true` 表示 handler 已负责稍后响应;返回 `false` 则由 dispatch_message
/// 直接回复 method-not-found。handler 应通过 [`AcpClient::respond_to_incoming`]
/// 或 [`AcpClient::respond_to_incoming_error`] 回写响应。
type IncomingRequestHandler = Box<dyn Fn(Value, String, Value) -> bool + Send + 'static>;
/// incoming request handler 的线程安全容器。
type IncomingRequestHandlerMutex = std::sync::Mutex<Option<IncomingRequestHandler>>;
// ── Pending request entry ────────────────────────────
type PendingEntry = oneshot::Sender<Result<Value, AcpError>>;
// ── AcpClient ────────────────────────────────────────
/// ACP JSON-RPC 2.0 client over stdio.
///
/// Create via [`AcpClient::spawn`], then use [`request`](Self::request) for RPC
/// calls and [`notification`](Self::notification) for fire-and-forget messages.
/// Register a handler with [`on_notification`](Self::on_notification) to receive
/// agent push events (e.g. `session/update`).
// Manual Debug impl: Child doesn't impl Debug, so we skip it
impl std::fmt::Debug for AcpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcpClient")
.field("next_id", &self.next_id)
.field("pending_count", &self.pending.blocking_lock().len())
.finish_non_exhaustive()
}
}
pub struct AcpClient {
child: Option<Child>,
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
next_id: AtomicU64,
notification_handler: Arc<NotificationHandlerMutex>,
/// agent 发来的 incoming JSON-RPC request handler(同时有 id 和 method)。
/// 未设置时会直接回复 method-not-found。
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
}
impl AcpClient {
/// Spawn an ACP subprocess and establish the JSON-RPC connection.
///
/// After spawn, sends an `initialize` handshake (as Hermes does in acpClient.ts
/// `start()` → `call('initialize', { protocolVersion: 1 })`).
/// Launches a background tokio task that reads NDJSON lines from the child's stdout.
///
/// Reference: `hermes-vscode-main/src/acpClient.ts` L50-80 (spawn + stdio setup)
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self, AcpError> {
Self::spawn_with_env(bin, args, None).await
}
/// Spawn an ACP subprocess with extra environment variables.
pub async fn spawn_with_env(
bin: &str,
args: &[&str],
env_overrides: Option<&HashMap<String, String>>,
) -> Result<Self, AcpError> {
let mut command = Command::new(bin);
command
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true);
if let Some(env) = env_overrides {
if let Some(workspace_root) = env.get("MNOTE_AI_WORKSPACE_ROOT") {
let workspace_root = std::path::Path::new(workspace_root);
if workspace_root.is_dir() {
// 本地 workspace run 以授权根目录作为进程工作目录,贴近 VSCode agent 行为。
command.current_dir(workspace_root);
}
}
command.envs(env);
}
let mut child = command.spawn().map_err(AcpError::Spawn)?;
let stdin = child
.stdin
.take()
.ok_or_else(|| AcpError::Internal("failed to take child stdin".into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| AcpError::Internal("failed to take child stdout".into()))?;
let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
let reader = BufReader::new(stdout);
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
let notification_handler: Arc<NotificationHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
let incoming_request_handler: Arc<IncomingRequestHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
// Start background reader task
let pending_clone = pending.clone();
let handler_clone = notification_handler.clone();
let incoming_clone = incoming_request_handler.clone();
let writer_clone = writer.clone();
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
Self::reader_loop(
reader,
writer_clone,
pending_clone,
handler_clone,
incoming_clone,
)
.await;
info!("ACP reader loop ended (pid={})", child_pid);
});
let client = Self {
child: Some(child),
writer,
pending,
next_id: AtomicU64::new(1),
notification_handler,
incoming_request_handler,
};
// Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1}))
let init_result: Value = client
.request("initialize", json!({ "protocolVersion": 1 }))
.await?;
debug!(?init_result, "ACP initialize OK");
Ok(client)
}
/// Send a JSON-RPC request and await the response.
///
/// Returns `Result<R>` where `R` is the deserialized `result` field.
/// On JSON-RPC error, returns [`AcpError::JsonRpc`].
/// Default timeout: 300 seconds. Override with `MNOTE_ACP_REQUEST_TIMEOUT_SECS`.
///
/// Reference: `acpClient.ts` L95-110 (`call()` method)
pub async fn request<P: Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: P,
) -> Result<R, AcpError> {
let timeout_secs = std::env::var("MNOTE_ACP_REQUEST_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value >= 30)
.unwrap_or(300);
self.request_with_timeout(method, params, Duration::from_secs(timeout_secs))
.await
}
/// Same as [`request`] but with a configurable timeout.
pub async fn request_with_timeout<P: Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: P,
dur: Duration,
) -> Result<R, AcpError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(id, tx);
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
let line = serde_json::to_string(&req)?;
debug!("ACP --> {} #{} ({} bytes)", method, id, line.len());
{
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
match timeout(dur, rx).await {
Ok(Ok(Ok(value))) => {
let result: R = serde_json::from_value(value)?;
Ok(result)
}
Ok(Ok(Err(err))) => Err(err),
Ok(Err(_recv_err)) => Err(AcpError::Closed),
Err(_elapsed) => Err(AcpError::Timeout(dur.as_secs())),
}
}
/// Send a fire-and-forget notification (no id, no response expected).
///
/// Reference: `acpClient.ts` L115-118 (`notify()`)
pub async fn notification(&self, method: &str, params: Value) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let line = serde_json::to_string(&msg)?;
debug!("ACP ~~> {} ({} bytes)", method, line.len());
{
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
Ok(())
}
/// Register a handler for incoming notifications (messages with `method` but no `id`).
/// Only one handler at a time — subsequent calls replace the previous.
pub fn on_notification<F>(&self, handler: F)
where
F: Fn(String, Value) + Send + 'static,
{
let mut guard = self.notification_handler.lock().unwrap();
*guard = Some(Box::new(handler));
}
/// 注册 incoming JSON-RPC request handler(消息同时包含 `id` 与 `method`)。
/// handler 接收原始 request id、method 和 params,并应稍后通过
/// [`respond_to_incoming`] 或 [`respond_to_incoming_error`] 响应。
/// 同一时间只保留一个 handler,后续注册会覆盖前一个。
pub fn on_incoming_request<F>(&self, handler: F)
where
F: Fn(Value, String, Value) -> bool + Send + 'static,
{
let mut guard = self.incoming_request_handler.lock().unwrap();
*guard = Some(Box::new(handler));
}
/// 用 result 响应 agent 发来的 incoming JSON-RPC request。
///
/// 必须使用 incoming request handler 收到的原始 `id`,避免丢失字符串 id。
pub async fn respond_to_incoming(&self, id: Value, result: Value) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"id": id.clone(),
"result": result,
});
debug!("ACP <-- respond to incoming #{}", id);
Self::write_jsonrpc_message(&self.writer, &msg).await
}
/// 用 error 响应 incoming JSON-RPC request。
pub async fn respond_to_incoming_error(
&self,
id: Value,
code: i64,
message: &str,
) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"id": id.clone(),
"error": {
"code": code,
"message": message,
},
});
debug!(
"ACP <-- respond error to incoming #{}: [{}] {}",
id, code, message
);
Self::write_jsonrpc_message(&self.writer, &msg).await
}
/// Gracefully close the ACP connection and kill the subprocess.
pub async fn close(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
let _ = child.wait().await;
}
// Resolve all pending with Closed error
let mut pending = self.pending.lock().await;
for (_, tx) in pending.drain() {
let _ = tx.send(Err(AcpError::Closed));
}
}
// ── Background reader ────────────────────────────
/// Background loop: reads NDJSON lines from the child's stdout,
/// routes responses to pending requests and notifications to the handler.
///
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
async fn reader_loop(
mut reader: BufReader<ChildStdout>,
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: Arc<NotificationHandlerMutex>,
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
) {
let mut line_buf = String::new();
loop {
line_buf.clear();
match reader.read_line(&mut line_buf).await {
Ok(0) => {
info!("ACP stdout closed (EOF)");
break;
}
Ok(_n) => {}
Err(e) => {
warn!("ACP read error: {e}");
break;
}
}
let trimmed = line_buf.trim();
if trimmed.is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
warn!(
"ACP parse error: {e} (line: {})",
&trimmed[..trimmed.len().min(80)]
);
continue;
}
};
Self::dispatch_message(
msg,
&writer,
&pending,
&notification_handler,
&incoming_request_handler,
)
.await;
}
// Process died or EOF — resolve all pending
let mut pending_guard = pending.lock().await;
for (_, tx) in pending_guard.drain() {
let _ = tx.send(Err(AcpError::Closed));
}
}
/// Route a single JSON message to pending request, notification handler, or incoming request.
///
/// Reference: `acpClient.ts` L160-200 (dispatch)
async fn dispatch_message(
msg: Value,
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: &Arc<NotificationHandlerMutex>,
incoming_request_handler: &Arc<IncomingRequestHandlerMutex>,
) {
let has_id = msg.get("id").is_some();
let has_method = msg
.get("method")
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false);
if has_id && has_method {
// agent 发来的 incoming request,例如 session/request_permission。
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let id_val = msg.get("id").cloned().unwrap_or(Value::Null);
// 若已注册 handler,则由 handler 决定是否负责稍后响应。
let handled = {
let handler_guard = incoming_request_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(id_val.clone(), method.clone(), params.clone())
} else {
false
}
};
if handled {
debug!("ACP incoming request dispatched: {method} #{}", id_val);
} else {
// 没有 handler 时必须立即响应,避免 agent 一直等待。
warn!("ACP incoming request not handled (no handler registered): {method}");
let response = json!({
"jsonrpc": "2.0",
"id": id_val,
"error": {
"code": -32601,
"message": format!("ACP incoming request not supported: {method}")
}
});
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
warn!("ACP incoming request response write failed: {error}");
}
}
} else if has_id {
// Response to one of our requests
if let Some(id) = msg["id"].as_u64() {
let mut pending_guard = pending.lock().await;
if let Some(tx) = pending_guard.remove(&id) {
if let Some(error) = msg.get("error") {
let code = error["code"].as_i64().unwrap_or(-1);
let message = error["message"]
.as_str()
.unwrap_or("unknown error")
.to_string();
let _ = tx.send(Err(AcpError::JsonRpc { code, message }));
} else if let Some(result) = msg.get("result") {
let _ = tx.send(Ok(result.clone()));
} else {
let _ = tx.send(Err(AcpError::Internal(
"response without result or error".into(),
)));
}
} else {
debug!("ACP response for unknown request id={id}");
}
}
} else if has_method {
// Notification (no id)
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let handler_guard = notification_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(method, params);
} else {
debug!("ACP notification unhandled: {method}");
}
}
}
async fn write_jsonrpc_message(
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
msg: &Value,
) -> Result<(), AcpError> {
let line = serde_json::to_string(msg)?;
let mut writer = writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}
}
impl Drop for AcpClient {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
}
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Helper: create a mock subprocess that echoes back requests as responses.
/// Simulates a minimal ACP server for testing.
async fn spawn_mock_acp_server() -> AcpClient {
// We spawn a small node script that reads NDJSON and echoes back
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method) {
if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
}) + '\n');
} else {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { ok: true, echo: msg.params }
}) + '\n');
}
} else if (msg.method && msg.id === undefined) {
// Notification → ignore
}
});
"#;
// Write script to temp file
let dir = std::env::temp_dir();
let script_path = dir.join("acp_test_mock.mjs");
std::fs::write(&script_path, script).expect("write mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn mock ACP")
}
async fn spawn_permission_request_mock_server() -> AcpClient {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
let permissionResponse = null;
const rl = readline.createInterface({ input, output, terminal: false });
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method === 'initialize') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
});
setTimeout(() => send({
jsonrpc: '2.0',
id: 77,
method: 'session/request_permission',
params: { reason: 'test permission' }
}), 10);
} else if (msg.id === 77 && msg.method === undefined) {
permissionResponse = msg;
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { permissionResponse }
});
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_permission_request_mock.mjs");
std::fs::write(&script_path, script).expect("write permission mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn permission mock ACP")
}
async fn spawn_string_id_permission_request_mock_server() -> AcpClient {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
let permissionResponse = null;
const rl = readline.createInterface({ input, output, terminal: false });
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method === 'initialize') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
});
setTimeout(() => send({
jsonrpc: '2.0',
id: 'perm-string-id',
method: 'session/request_permission',
params: { reason: 'test permission' }
}), 10);
} else if (msg.id === 'perm-string-id' && msg.method === undefined) {
permissionResponse = msg;
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { permissionResponse }
});
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_permission_request_string_id_mock.mjs");
std::fs::write(&script_path, script).expect("write permission string id mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn permission string id mock ACP")
}
#[tokio::test]
async fn test_request_response() {
let client = spawn_mock_acp_server().await;
let result: Value = client
.request("test_method", json!({ "hello": "world" }))
.await
.expect("request should succeed");
assert_eq!(result["ok"], true);
assert_eq!(result["echo"]["hello"], "world");
}
#[tokio::test]
async fn test_notification() {
let client = spawn_mock_acp_server().await;
// Notifications are fire-and-forget, no response expected
client
.notification("test_notify", json!({ "foo": "bar" }))
.await
.expect("notification should succeed");
}
#[tokio::test]
async fn test_on_notification_received() {
use std::sync::atomic::AtomicBool;
let client = spawn_mock_acp_server().await;
let received = Arc::new(AtomicBool::new(false));
let received_clone = received.clone();
client.on_notification(move |method, _params| {
if method == "test_push" {
received_clone.store(true, Ordering::SeqCst);
}
});
// Send a notification that the mock server will echo back as...
// Actually the mock doesn't send unsolicited notifications.
// This test just validates the handler registration doesn't crash.
client
.notification("test_push", json!({}))
.await
.expect("notification");
// Give background task time to process
tokio::time::sleep(Duration::from_millis(100)).await;
// In this mock, no notification will be received; that's OK
}
#[tokio::test]
async fn test_close() {
let mut client = spawn_mock_acp_server().await;
client.close().await;
// Second close should be no-op
client.close().await;
}
#[tokio::test]
async fn test_initialize_handshake() {
// spawn already calls initialize; if it fails, the test fails
let _client = spawn_mock_acp_server().await;
}
#[tokio::test]
async fn test_incoming_permission_request_gets_response() {
let client = spawn_permission_request_mock_server().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let result: Value = client
.request("get_permission_response", json!({}))
.await
.expect("permission response probe");
let response = &result["permissionResponse"];
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 77);
assert!(response.get("result").is_some() || response.get("error").is_some());
}
#[tokio::test]
async fn test_incoming_permission_request_preserves_string_id() {
let client = spawn_string_id_permission_request_mock_server().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let result: Value = client
.request("get_permission_response", json!({}))
.await
.expect("permission response probe");
let response = &result["permissionResponse"];
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], "perm-string-id");
assert!(response.get("result").is_some() || response.get("error").is_some());
}
}
-391
View File
@@ -1,391 +0,0 @@
/// ACP Runtime Manager — manages agent runtime subprocess lifecycle.
///
/// Supports multiple runtimes (Hermes, Reasonix) and switching between them.
/// Each runtime is spawned as a subprocess communicating via the ACP JSON-RPC 2.0 protocol.
///
/// Configuration:
/// `MNOTE_WEB_ACP_DEFAULT_RUNTIME` — default runtime name ("hermes" | "reasonix")
/// `MNOTE_WEB_HERMES_BIN` — path to Hermes binary (default: "hermes")
/// `MNOTE_WEB_HERMES_ACP_PROFILE` — Hermes profile for ACP runtime (default: "default")
/// `MNOTE_WEB_REASONIX_WRAPPER` — path to Reasonix wrapper script (default: "scripts/reasonix-acp-wrapper.mjs")
///
/// Or via JSON env var:
/// `MNOTE_WEB_ACP_RUNTIMES` — JSON array of runtime configs
use crate::acp_client::{AcpClient, AcpError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
// ── Config ───────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpRuntimeConfig {
/// Display name (e.g. "hermes", "reasonix").
pub name: String,
/// Binary path (e.g. "hermes", "node").
pub bin: String,
/// Command arguments (e.g. ["acp"], ["scripts/reasonix-acp-wrapper.mjs"]).
#[serde(default)]
pub args: Vec<String>,
/// Extra environment variables.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
/// Human-readable title for the runtime selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl AcpRuntimeConfig {
/// Create a Hermes ACP runtime config.
pub fn hermes(bin: Option<&str>, profile: Option<&str>) -> Self {
let profile = profile.unwrap_or("default").trim();
let args = if profile.is_empty() {
vec!["acp".into()]
} else {
vec!["-p".into(), profile.to_string(), "acp".into()]
};
Self {
name: "hermes".into(),
bin: bin.unwrap_or("hermes").to_string(),
args,
env: None,
title: Some("Hermes".into()),
}
}
/// Create a Reasonix ACP runtime config.
/// `wrapper_path` is relative to the project root (where Cargo.toml's parent is).
/// Default: `design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/scripts/reasonix-acp-wrapper.mjs` (dev),
/// or in production, the absolute path is resolved via `CARGO_MANIFEST_DIR` (the `rust/` directory).
pub fn reasonix(wrapper_path: Option<&str>) -> Self {
// CARGO_MANIFEST_DIR is the directory containing this crate's Cargo.toml:
// /mnt/Data1T/mnote/rust/crates/mnote-web/
// We need the project root: /mnt/Data1T/mnote/
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3) // up: mnote-web/ → crates/ → rust/ → mnote/ (project root)
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
.to_string_lossy()
.to_string();
let default_path = format!("{project_root}/scripts/reasonix-acp-wrapper.mjs");
// Resolve wrapper_path: if it's relative, prepend project_root; absolute paths used as-is
let resolved_path = wrapper_path
.map(|p| {
if p.starts_with('/') {
p.to_string()
} else {
format!("{project_root}/{p}")
}
})
.unwrap_or(default_path);
Self {
name: "reasonix".into(),
bin: "node".into(),
args: vec![resolved_path],
env: None,
title: Some("Reasonix".into()),
}
}
}
// ── Runtime Manager ──────────────────────────────────
/// Manages lifecycle of multiple agent runtimes.
///
/// Each runtime is defined by a name and spawn configuration.
/// At most one runtime is "active" at a time, providing an [`AcpClient`].
#[derive(Debug)]
pub struct AcpRuntimeManager {
runtimes: HashMap<String, AcpRuntimeConfig>,
active: Mutex<Option<ActiveRuntime>>,
default_runtime: String,
}
#[derive(Debug)]
struct ActiveRuntime {
config: AcpRuntimeConfig,
client: Arc<AcpClient>,
}
impl AcpRuntimeManager {
/// Create a new runtime manager with built-in default configurations.
///
/// Reads environment variables to configure Hermes and Reasonix runtimes.
/// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "reasonix").
pub fn from_env() -> Self {
let mut runtimes: HashMap<String, AcpRuntimeConfig> = HashMap::new();
// Check for JSON-based configuration first
if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") {
if let Ok(custom_runtimes) = serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json) {
for rt in custom_runtimes {
let name = rt.name.clone();
runtimes.insert(name, rt);
}
} else {
warn!("Failed to parse MNOTE_WEB_ACP_RUNTIMES JSON");
}
}
// Always add default Hermes if not already configured
if !runtimes.contains_key("hermes") {
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN").unwrap_or_else(|_| "hermes".into());
let hermes_profile =
env::var("MNOTE_WEB_HERMES_ACP_PROFILE").unwrap_or_else(|_| "default".into());
runtimes.insert(
"hermes".into(),
AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(&hermes_profile)),
);
}
// Always add default Reasonix if not already configured
if !runtimes.contains_key("reasonix") {
let wrapper = env::var("MNOTE_WEB_REASONIX_WRAPPER")
.unwrap_or_else(|_| "scripts/reasonix-acp-wrapper.mjs".into());
runtimes.insert(
"reasonix".into(),
AcpRuntimeConfig::reasonix(Some(&wrapper)),
);
}
let default =
env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME").unwrap_or_else(|_| "reasonix".into());
Self {
runtimes,
active: Mutex::new(None),
default_runtime: default,
}
}
/// Get the list of available runtime names.
pub fn available_runtimes(&self) -> Vec<String> {
self.runtimes.keys().cloned().collect()
}
/// Get a runtime config by name.
pub fn get_config(&self, name: &str) -> Option<&AcpRuntimeConfig> {
self.runtimes.get(name)
}
/// Get the default runtime name.
pub fn default_runtime(&self) -> &str {
&self.default_runtime
}
/// Get the currently active runtime name, if any.
pub async fn active_runtime_name(&self) -> Option<String> {
self.active
.lock()
.await
.as_ref()
.map(|a| a.config.name.clone())
}
/// Get a reference to the currently active [`AcpClient`], if any.
pub async fn active_client(&self) -> Option<Arc<AcpClient>> {
self.active.lock().await.as_ref().map(|a| a.client.clone())
}
/// Check if a runtime is active and the client is available.
pub async fn is_active(&self) -> bool {
self.active.lock().await.is_some()
}
/// Activate a runtime by name, spawning a new subprocess if needed.
///
/// If another runtime is currently active, it will be shut down first.
/// After spawn, performs an `initialize` handshake to verify the runtime is healthy.
pub async fn switch_to(&self, name: &str) -> Result<Arc<AcpClient>, AcpError> {
let config = self
.runtimes
.get(name)
.cloned()
.ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?;
self.switch_to_config(config).await
}
/// Activate a runtime from an explicit config.
///
/// This is used by Hermes ACP because the binary is the same runtime name,
/// but the selected Hermes profile changes the launch args.
pub async fn switch_to_config(
&self,
config: AcpRuntimeConfig,
) -> Result<Arc<AcpClient>, AcpError> {
let name = config.name.clone();
// Shutdown current active runtime
let mut active_guard = self.active.lock().await;
if let Some(ref current) = *active_guard {
if current.config == config {
// Already active — return existing client
return Ok(current.client.clone());
}
// Drop the old ActiveRuntime, which will kill the child process
// (via AcpClient's Drop impl)
}
info!(
"ACP runtime: switching to {name} (bin={}, args={:?})",
config.bin, config.args
);
// Convert Vec<String> to Vec<&str> for AcpClient::spawn
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
let client =
AcpClient::spawn_with_env(&config.bin, &args_refs, config.env.as_ref()).await?;
let client = Arc::new(client);
*active_guard = Some(ActiveRuntime {
config,
client: client.clone(),
});
info!("ACP runtime: {name} active");
Ok(client)
}
/// Shut down the currently active runtime.
pub async fn shutdown_active(&self) {
let mut active_guard = self.active.lock().await;
if let Some(active) = active_guard.take() {
info!("ACP runtime: shutting down {}", active.config.name);
// AcpClient's Drop kills the process
}
}
/// Perform a health check on the active runtime.
///
/// Returns `true` if the runtime responds to an `initialize` handshake within 5 seconds.
pub async fn health_check(&self) -> bool {
let client = match self.active_client().await {
Some(c) => c,
None => return false,
};
// Use request_with_timeout with a short timeout
let result: Result<serde_json::Value, AcpError> = timeout(
Duration::from_secs(5),
client.request("initialize", serde_json::json!({ "protocolVersion": 1 })),
)
.await
.map_err(|_| AcpError::Timeout(5))
.and_then(|r| r);
match result {
Ok(val) => {
let ok = val.get("protocolVersion").and_then(|v| v.as_u64()) == Some(1);
if ok {
debug!("ACP health check OK");
} else {
warn!("ACP health check: unexpected response: {val:?}");
}
ok
}
Err(e) => {
warn!("ACP health check failed: {e}");
false
}
}
}
}
impl Drop for AcpRuntimeManager {
fn drop(&mut self) {
// The active runtime's AcpClient Drop will kill the process
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_config_hermes() {
let cfg = AcpRuntimeConfig::hermes(None, None);
assert_eq!(cfg.name, "hermes");
assert_eq!(cfg.bin, "hermes");
assert_eq!(cfg.args, vec!["-p", "default", "acp"]);
}
#[test]
fn test_runtime_config_hermes_profile_can_be_disabled() {
let cfg = AcpRuntimeConfig::hermes(None, Some(""));
assert_eq!(cfg.args, vec!["acp"]);
}
#[test]
fn test_runtime_config_reasonix() {
let cfg = AcpRuntimeConfig::reasonix(None);
assert_eq!(cfg.name, "reasonix");
assert_eq!(cfg.bin, "node");
assert_eq!(cfg.args.len(), 1);
assert!(cfg.args[0].ends_with("/scripts/reasonix-acp-wrapper.mjs"));
}
#[test]
fn test_runtime_config_custom() {
let cfg = AcpRuntimeConfig {
name: "custom".into(),
bin: "/usr/local/bin/my-agent".into(),
args: vec!["--acp".into(), "--debug".into()],
env: None,
title: Some("My Agent".into()),
};
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(json["name"], "custom");
assert_eq!(json["bin"], "/usr/local/bin/my-agent");
assert_eq!(json["title"], "My Agent");
}
#[test]
fn test_runtime_manager_from_env_defaults() {
// Without env overrides, should contain hermes and reasonix
let mgr = AcpRuntimeManager::from_env();
let runtimes = mgr.available_runtimes();
assert!(runtimes.contains(&"hermes".into()));
assert!(runtimes.contains(&"reasonix".into()));
}
#[tokio::test]
async fn test_switch_to_unknown_runtime() {
let mgr = AcpRuntimeManager::from_env();
let result = mgr.switch_to("nonexistent").await;
assert!(result.is_err());
let err_str = format!("{}", result.err().unwrap());
assert!(
err_str.contains("unknown runtime"),
"should return error for unknown runtime, got: {err_str}"
);
}
#[tokio::test]
async fn test_health_check_no_active() {
let mgr = AcpRuntimeManager::from_env();
assert!(!mgr.health_check().await, "no active runtime = unhealthy");
}
#[tokio::test]
async fn test_switch_to_hermes_requires_binary() {
let mgr = AcpRuntimeManager::from_env();
// This might fail if `hermes` binary is not in PATH — that's OK for this test
let result = mgr.switch_to("hermes").await;
// We just verify it doesn't panic; either succeeds or returns Spawn error
if let Err(e) = &result {
assert!(
matches!(e, AcpError::Spawn(_)),
"expected Spawn error if hermes not in PATH, got: {e}"
);
} else {
// Success — clean up
mgr.shutdown_active().await;
}
}
}
File diff suppressed because it is too large Load Diff
-802
View File
@@ -1,802 +0,0 @@
/// ACP (Agent Client Protocol) type definitions.
///
/// Strongly-typed Rust representations of the ACP JSON-RPC 2.0 messages.
/// Both Hermes (`hermes acp`) and Reasonix share this protocol shape.
///
/// Reference:
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
/// - `reference-code/hermes-vscode-main/src/protocol.ts`
use serde::{de, Deserialize, Deserializer, Serialize};
use serde_json::Value;
// ── JSON-RPC 2.0 basics ──────────────────────────────
pub type JsonRpcId = serde_json::Value; // number or string
// ── Initialize ───────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
pub protocol_version: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_capabilities: Option<ClientCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_info: Option<ClientInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub fs: Option<FsCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FsCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub read_text_file: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub write_text_file: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResult {
pub protocol_version: u64,
pub agent_capabilities: AgentCapabilities,
pub agent_info: AgentInfo,
pub auth_methods: Vec<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub load_session: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_capabilities: Option<PromptCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_capabilities: Option<McpCapabilities>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedded_context: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub http: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sse: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub version: String,
}
// ── Session ──────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<McpServerSpec>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerSpec {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewResult {
pub session_id: String,
}
// ── Session load ─────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionLoadParams {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<McpServerSpec>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionLoadResult {
pub session_id: String,
}
// ── Content blocks ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "resource")]
Resource { resource: ResourceContent },
#[serde(rename = "image")]
Image { mime_type: String, data: String },
#[serde(rename = "audio")]
Audio { mime_type: String, data: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceContent {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
// ── Session prompt ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptParams {
pub session_id: String,
pub prompt: Vec<ContentBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mnote_session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mnote_capabilities: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptResult {
pub stop_reason: StopReason,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
ToolUseComplete,
Cancelled,
Error,
}
// ── Session cancel (notification, no result) ─────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionCancelParams {
pub session_id: String,
}
// ── Session update (notification from agent to client) ──
/// The `session/update` notification payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUpdateParams {
pub session_id: String,
pub update: SessionUpdate,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum SessionUpdate {
AgentMessageChunk {
#[serde(rename = "sessionUpdate")]
session_update: String, // "agent_message_chunk"
content: TextContent,
},
AgentThoughtChunk {
#[serde(rename = "sessionUpdate")]
session_update: String, // "agent_thought_chunk"
content: TextContent,
},
ToolCall {
#[serde(rename = "sessionUpdate")]
session_update: String, // "tool_call"
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<ToolCallKind>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
raw_input: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
locations: Vec<ToolLocation>,
},
ToolCallUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "tool_call_update"
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Vec<ContentBlockWrapper>>,
},
Plan {
#[serde(rename = "sessionUpdate")]
session_update: String, // "plan"
entries: Vec<PlanEntry>,
},
UsageUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "usage_update"
used: u64,
size: u64,
},
SessionInfoUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "session_info_update"
title: String,
},
/// Catch-all for any future/unknown session update variants.
Unknown {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(flatten)]
extra: std::collections::HashMap<String, Value>,
},
}
impl<'de> Deserialize<'de> for SessionUpdate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let kind = value
.get("sessionUpdate")
.and_then(Value::as_str)
.ok_or_else(|| de::Error::missing_field("sessionUpdate"))?
.to_string();
match kind.as_str() {
SessionUpdate::AGENT_MESSAGE_CHUNK => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
content: TextContent,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::AgentMessageChunk {
session_update: raw.session_update,
content: raw.content,
})
}
SessionUpdate::AGENT_THOUGHT_CHUNK => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
content: TextContent,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::AgentThoughtChunk {
session_update: raw.session_update,
content: raw.content,
})
}
SessionUpdate::TOOL_CALL => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(rename = "toolCallId")]
tool_call_id: String,
title: Option<String>,
kind: Option<ToolCallKind>,
status: Option<ToolCallStatus>,
raw_input: Option<Value>,
#[serde(default)]
locations: Vec<ToolLocation>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::ToolCall {
session_update: raw.session_update,
tool_call_id: raw.tool_call_id,
title: raw.title,
kind: raw.kind,
status: raw.status,
raw_input: raw.raw_input,
locations: raw.locations,
})
}
SessionUpdate::TOOL_CALL_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(rename = "toolCallId")]
tool_call_id: String,
status: Option<ToolCallStatus>,
content: Option<Vec<ContentBlockWrapper>>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::ToolCallUpdate {
session_update: raw.session_update,
tool_call_id: raw.tool_call_id,
status: raw.status,
content: raw.content,
})
}
SessionUpdate::PLAN => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
entries: Vec<PlanEntry>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::Plan {
session_update: raw.session_update,
entries: raw.entries,
})
}
SessionUpdate::USAGE_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
used: u64,
size: u64,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::UsageUpdate {
session_update: raw.session_update,
used: raw.used,
size: raw.size,
})
}
SessionUpdate::SESSION_INFO_UPDATE => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Raw {
#[serde(rename = "sessionUpdate")]
session_update: String,
title: String,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
Ok(SessionUpdate::SessionInfoUpdate {
session_update: raw.session_update,
title: raw.title,
})
}
_ => {
let mut extra = match value {
Value::Object(map) => map.into_iter().collect(),
_ => std::collections::HashMap::new(),
};
extra.remove("sessionUpdate");
Ok(SessionUpdate::Unknown {
session_update: kind,
extra,
})
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextContent {
#[serde(rename = "type")]
pub content_type: String, // "text"
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentBlockWrapper {
#[serde(rename = "type")]
pub wrapper_type: String, // "content"
pub content: TextContent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallKind {
Read,
Edit,
Search,
Execute,
Other,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallStatus {
Pending,
InProgress,
Completed,
Failed,
}
/// A file path location referenced by a tool call.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolLocation {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanEntry {
pub content: String,
pub priority: PlanPriority,
pub status: PlanEntryStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanPriority {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanEntryStatus {
Pending,
InProgress,
Completed,
}
// ── Permission request (from agent to client) ────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestParams {
pub session_id: String,
pub tool_call: PermissionToolCall,
pub options: Vec<PermissionOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionToolCall {
#[serde(rename = "toolCallId")]
pub tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<ToolCallKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_input: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionOption {
pub option_id: String,
pub name: String,
pub kind: PermissionOptionKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionOptionKind {
AllowOnce,
AllowAlways,
RejectOnce,
RejectAlways,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestResult {
pub outcome: PermissionOutcome,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PermissionOutcome {
Selected { outcome: String, option_id: String },
Cancelled { outcome: String },
}
// ── Error codes (JSON-RPC standard) ──────────────────
pub const ERR_PARSE: i64 = -32700;
pub const ERR_INVALID_REQUEST: i64 = -32600;
pub const ERR_METHOD_NOT_FOUND: i64 = -32601;
pub const ERR_INVALID_PARAMS: i64 = -32602;
pub const ERR_INTERNAL: i64 = -32603;
// ── Session update kind discriminants ────────────────
/// Constants for the `sessionUpdate` string field.
impl SessionUpdate {
pub const AGENT_MESSAGE_CHUNK: &'static str = "agent_message_chunk";
pub const AGENT_THOUGHT_CHUNK: &'static str = "agent_thought_chunk";
pub const TOOL_CALL: &'static str = "tool_call";
pub const TOOL_CALL_UPDATE: &'static str = "tool_call_update";
pub const PLAN: &'static str = "plan";
pub const USAGE_UPDATE: &'static str = "usage_update";
pub const SESSION_INFO_UPDATE: &'static str = "session_info_update";
}
/// Parse the `sessionUpdate` string field from a raw JSON value and return the discriminant.
pub fn session_update_kind<'a>(value: &'a serde_json::Value) -> Option<&'a str> {
value
.get("update")
.and_then(|u| u.get("sessionUpdate"))
.and_then(|v| v.as_str())
}
// ── Helper: extract text from agent_message_chunk / agent_thought_chunk ──
/// Extract the text content from a session update's content block.
/// Returns `None` for non-text updates or malformed content.
///
/// Reference: `hermes-vscode-main/src/protocol.ts` `extractTextContent()`
pub fn extract_text_from_update(update: &SessionUpdate) -> Option<&str> {
match update {
SessionUpdate::AgentMessageChunk { content, .. }
| SessionUpdate::AgentThoughtChunk { content, .. } => Some(&content.text),
_ => None,
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initialize_params_roundtrip() {
let params = InitializeParams {
protocol_version: 1,
client_capabilities: None,
client_info: Some(ClientInfo {
name: "mnote-web".into(),
title: Some("MNote".into()),
version: Some("0.1.0".into()),
}),
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["protocolVersion"], 1);
assert_eq!(json["clientInfo"]["name"], "mnote-web");
let deserialized: InitializeParams = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.protocol_version, 1);
}
#[test]
fn test_session_new_params() {
let params = SessionNewParams {
cwd: Some("/mnt/Data1T/mnote".into()),
mcp_servers: Some(Vec::new()),
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["cwd"], "/mnt/Data1T/mnote");
assert_eq!(json["mcpServers"], serde_json::json!([]));
}
#[test]
fn test_session_update_agent_message_chunk() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "Hello" }
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.session_id, "test_1");
match &parsed.update {
SessionUpdate::AgentMessageChunk { content, .. } => {
assert_eq!(content.text, "Hello");
}
_ => panic!("expected AgentMessageChunk"),
}
}
#[test]
fn test_session_update_agent_thought_chunk() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "agent_thought_chunk",
"content": { "type": "text", "text": "thinking" }
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.session_id, "test_1");
match &parsed.update {
SessionUpdate::AgentThoughtChunk { content, .. } => {
assert_eq!(content.text, "thinking");
}
_ => panic!("expected AgentThoughtChunk"),
}
}
#[test]
fn test_session_update_tool_call() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tc_1",
"title": "mnote.doc.fetch",
"kind": "read",
"status": "pending"
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::ToolCall {
title,
kind,
locations,
..
} => {
assert_eq!(title.as_deref(), Some("mnote.doc.fetch"));
assert!(matches!(kind, Some(ToolCallKind::Read)));
assert!(locations.is_empty(), "no locations in this fixture");
}
_ => panic!("expected ToolCall"),
}
}
#[test]
fn test_session_update_tool_call_with_locations() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tc_2",
"title": "mnote.doc.read",
"kind": "read",
"status": "in_progress",
"locations": [
{ "path": "/mnt/Data1T/mnote/src/main.rs" },
{ "path": "/mnt/Data1T/mnote/src/lib.rs" }
]
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::ToolCall {
tool_call_id,
locations,
..
} => {
assert_eq!(tool_call_id, "tc_2");
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].path, "/mnt/Data1T/mnote/src/main.rs");
assert_eq!(locations[1].path, "/mnt/Data1T/mnote/src/lib.rs");
}
_ => panic!("expected ToolCall"),
}
}
#[test]
fn test_session_update_usage() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "usage_update",
"used": 1500,
"size": 4000
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::UsageUpdate { used, size, .. } => {
assert_eq!(*used, 1500);
assert_eq!(*size, 4000);
}
_ => panic!("expected UsageUpdate"),
}
}
#[test]
fn test_content_block_text() {
let block = ContentBlock::Text {
text: "hello".into(),
};
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hello");
}
#[test]
fn test_flatten_prompt() {
let blocks = vec![
ContentBlock::Text {
text: "Hello".into(),
},
ContentBlock::Resource {
resource: ResourceContent {
uri: "file:///test.md".into(),
mime_type: None,
text: Some(" world".into()),
},
},
];
// flattenPrompt equivalent: concatenate text blocks + resource text
let text: Vec<String> = blocks
.iter()
.map(|b| match b {
ContentBlock::Text { text } => text.clone(),
ContentBlock::Resource { resource } => resource.text.clone().unwrap_or_default(),
_ => String::new(),
})
.collect();
assert_eq!(text.join(""), "Hello world");
}
}
-368
View File
@@ -1,368 +0,0 @@
use crate::acp_bridge::SseEvent;
use serde_json::{json, Value};
use std::env;
const DEFAULT_API_CHAT_BASE_URL: &str = "http://127.0.0.1:20128/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApiChatProfile {
pub profile_id: &'static str,
pub base_profile: &'static str,
pub isolated_profile: &'static str,
pub label: &'static str,
pub model: &'static str,
pub provider_kind: &'static str,
pub status: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedApiChatProfile {
pub profile_id: String,
pub base_profile: String,
pub isolated_profile: String,
pub label: String,
pub model: String,
pub provider_kind: String,
pub status: String,
pub base_url: String,
pub api_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiChatError {
pub code: &'static str,
pub message: String,
}
impl ApiChatError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl std::fmt::Display for ApiChatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ApiChatError {}
pub const API_CHAT_PROFILES: &[ApiChatProfile] = &[
ApiChatProfile {
profile_id: "shared_api_deepseek_flash_chat",
base_profile: "api-deepseek-flash-chat",
isolated_profile: "api-deepseek-flash-chat",
label: "DeepSeek Flash Chat",
model: "deepseek-v4-flash",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_deepseek_pro_chat",
base_profile: "api-deepseek-pro-chat",
isolated_profile: "api-deepseek-pro-chat",
label: "DeepSeek Pro Chat",
model: "deepseek-v4-pro",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_gpt_chat",
base_profile: "api-gpt-chat",
isolated_profile: "api-gpt-chat",
label: "GPT Chat",
model: "aisz-chat/gpt-5.5-extra-high-fast",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_kimi_chat",
base_profile: "api-kimi-chat",
isolated_profile: "api-kimi-chat",
label: "Kimi Chat",
model: "aisz-chat/kimi-k2.5",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_gemini_chat",
base_profile: "api-gemini-chat",
isolated_profile: "api-gemini-chat",
label: "Gemini API Chat",
model: "aisz-chat/gemini-3.1-pro",
provider_kind: "api-chat",
status: "active",
},
ApiChatProfile {
profile_id: "shared_api_grok_chat",
base_profile: "api-grok-chat",
isolated_profile: "api-grok-chat",
label: "Grok API Chat",
model: "aisz-chat/grok-4.3",
provider_kind: "api-chat",
status: "active",
},
];
pub fn api_chat_profiles() -> &'static [ApiChatProfile] {
API_CHAT_PROFILES
}
pub fn api_chat_profile_by_id(value: &str) -> Option<ApiChatProfile> {
let needle = value.trim();
if needle.is_empty() {
return None;
}
API_CHAT_PROFILES
.iter()
.copied()
.find(|profile| profile_matches(*profile, needle))
}
pub fn payload_uses_api_chat_profile(payload: &Value, registration_profile: &str) -> bool {
let agent_id = payload
.get("agentId")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if agent_id != "chat_only" {
return false;
}
let agent_profile = payload.get("agentProfileRef");
[
Some(registration_profile),
payload.get("profile").and_then(Value::as_str),
payload.get("profileId").and_then(Value::as_str),
payload.get("profile_id").and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("baseProfile"))
.and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("isolatedProfile"))
.and_then(Value::as_str),
agent_profile
.and_then(|value| value.get("profileId"))
.and_then(Value::as_str),
]
.into_iter()
.flatten()
.any(|candidate| api_chat_profile_by_id(candidate).is_some())
}
pub fn resolve_api_chat_profile(value: &str) -> Result<ResolvedApiChatProfile, ApiChatError> {
let profile = api_chat_profile_by_id(value).ok_or_else(|| {
ApiChatError::new(
"api_chat_profile_unknown",
format!("未知 API Chat profile: {value}"),
)
})?;
let env_prefix = profile_env_prefix(profile.profile_id);
let model = env_value(&format!("{env_prefix}_MODEL"))
.or_else(|| env_value("MNOTE_API_CHAT_MODEL"))
.unwrap_or_else(|| profile.model.to_string());
let base_url = env_value(&format!("{env_prefix}_BASE_URL"))
.or_else(|| env_value("MNOTE_API_CHAT_BASE_URL"))
.unwrap_or_else(|| DEFAULT_API_CHAT_BASE_URL.to_string());
let api_key = env_value(&format!("{env_prefix}_API_KEY"))
.or_else(|| env_value("MNOTE_API_CHAT_API_KEY"))
.or_else(|| env_value("OPENAI_API_KEY"));
Ok(ResolvedApiChatProfile {
profile_id: profile.profile_id.to_string(),
base_profile: profile.base_profile.to_string(),
isolated_profile: profile.isolated_profile.to_string(),
label: profile.label.to_string(),
model,
provider_kind: profile.provider_kind.to_string(),
status: profile.status.to_string(),
base_url: base_url.trim().trim_end_matches('/').to_string(),
api_key,
})
}
pub fn runtime_events_from_openai_sse_chunk(
run_id: &str,
chunk: &str,
) -> Result<Vec<SseEvent>, ApiChatError> {
let mut decoder = OpenAiSseDecoder::default();
decoder.push_chunk(run_id, chunk)
}
#[derive(Debug, Default)]
pub struct OpenAiSseDecoder {
buffer: String,
output: String,
completed: bool,
}
impl OpenAiSseDecoder {
pub fn push_chunk(&mut self, run_id: &str, chunk: &str) -> Result<Vec<SseEvent>, ApiChatError> {
self.buffer.push_str(chunk);
let mut frames = self
.buffer
.split("\n\n")
.map(str::to_string)
.collect::<Vec<_>>();
self.buffer = frames.pop().unwrap_or_default();
let mut events = Vec::new();
for frame in frames {
events.extend(self.parse_frame(run_id, &frame)?);
}
Ok(events)
}
pub fn finish(&mut self, run_id: &str) -> Result<Vec<SseEvent>, ApiChatError> {
let rest = std::mem::take(&mut self.buffer);
let mut events = if rest.trim().is_empty() {
Vec::new()
} else {
self.parse_frame(run_id, &rest)?
};
if !self.completed {
self.completed = true;
events.push(self.completed_event());
}
Ok(events)
}
fn parse_frame(&mut self, run_id: &str, frame: &str) -> Result<Vec<SseEvent>, ApiChatError> {
let mut events = Vec::new();
for data in sse_frame_data_lines(frame) {
if data == "[DONE]" {
if !self.completed {
self.completed = true;
events.push(self.completed_event());
}
continue;
}
let payload = serde_json::from_str::<Value>(&data).map_err(|error| {
ApiChatError::new(
"api_chat_stream_parse_error",
format!("OpenAI SSE chunk 解析失败: {error}"),
)
})?;
if let Some(error) = payload.get("error") {
self.completed = true;
events.push(SseEvent {
event: "run.failed".into(),
data: json!({
"runId": run_id,
"code": "api_chat_upstream_error",
"message": error
.get("message")
.and_then(Value::as_str)
.unwrap_or("API Chat upstream error"),
"error": error
}),
});
continue;
}
for delta in extract_delta_texts(&payload) {
self.output.push_str(&delta);
events.push(SseEvent {
event: "message.delta".into(),
data: json!({
"runId": run_id,
"delta": delta
}),
});
}
}
Ok(events)
}
fn completed_event(&self) -> SseEvent {
SseEvent {
event: "run.completed".into(),
data: json!({
"output": self.output
}),
}
}
}
fn profile_matches(profile: ApiChatProfile, value: &str) -> bool {
profile.profile_id == value
|| profile.base_profile == value
|| profile.isolated_profile == value
|| profile.label == value
}
fn profile_env_prefix(profile_id: &str) -> String {
let suffix = profile_id
.trim()
.strip_prefix("shared_api_")
.unwrap_or(profile_id)
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
format!("MNOTE_API_CHAT_{suffix}")
}
fn env_value(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn sse_frame_data_lines(frame: &str) -> Vec<String> {
let mut lines = Vec::new();
for line in frame.lines() {
let trimmed = line.trim();
if let Some(data) = trimmed.strip_prefix("data:") {
lines.push(data.trim().to_string());
}
}
lines
}
fn extract_delta_texts(payload: &Value) -> Vec<String> {
let mut texts = Vec::new();
if let Some(choices) = payload.get("choices").and_then(Value::as_array) {
for choice in choices {
for value in [
choice
.get("delta")
.and_then(|delta| delta.get("content"))
.and_then(Value::as_str),
choice
.get("message")
.and_then(|message| message.get("content"))
.and_then(Value::as_str),
choice.get("text").and_then(Value::as_str),
]
.into_iter()
.flatten()
{
if !value.is_empty() {
texts.push(value.to_string());
}
}
}
}
if texts.is_empty() {
for value in [
payload.get("delta").and_then(Value::as_str),
payload.get("text").and_then(Value::as_str),
payload.get("content").and_then(Value::as_str),
]
.into_iter()
.flatten()
{
if !value.is_empty() {
texts.push(value.to_string());
}
}
}
texts
}
-7
View File
@@ -1,4 +1,3 @@
use crate::acp_runtime::AcpRuntimeManager;
use crate::document_buffer_store::BufferStore;
use crate::editor_actor::EditorRuntimeActor;
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
@@ -33,7 +32,6 @@ pub struct AppConfig {
pub enable_debug_shell_routes: bool,
pub enable_editor_actor: bool,
pub enable_page_ai_pi_lab: bool,
pub hermes_base_path: String,
pub compat_next_base_path: String,
pub convex_url: Option<String>,
pub convex_admin_key: Option<String>,
@@ -67,8 +65,6 @@ impl AppConfig {
.unwrap_or(false),
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true),
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
.unwrap_or_else(|_| "/api/hermes".into()),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
.unwrap_or_else(|_| "/api/compat/next".into()),
convex_url: None,
@@ -163,7 +159,6 @@ pub struct AppState {
pub editor_actor: EditorRuntimeActor,
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
pub acp_runtime: Arc<AcpRuntimeManager>,
pub buffer_store: BufferStore,
control_plane: Arc<dyn ControlPlaneStore>,
}
@@ -185,7 +180,6 @@ impl AppState {
editor_actor: actor,
block_delta_tx,
stream_delta_tx,
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
buffer_store,
control_plane,
}
@@ -389,7 +383,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+1 -1
View File
@@ -192,7 +192,7 @@ mod tests {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/hermes/bridge".parse::<Uri>().expect("uri"),
&"/api/mnote/tools".parse::<Uri>().expect("uri"),
&headers,
);
@@ -358,7 +358,7 @@ pub fn buffer_key_string(path: &ObjectWorkspacePath) -> String {
/// 从本地文件夹写入上下文的参数构建 ObjectWorkspacePath。
///
/// 在 save_local_markdown_page、watcher event 和 Hermes 写入链中统一使用此函数构造路径。
/// 在 save_local_markdown_page、watcher event 和 agent tool 写入链中统一使用此函数构造路径。
pub fn build_local_folder_workspace_path(
workspace_id: &str,
root_uri: &str,
+2 -8
View File
@@ -1,18 +1,12 @@
#![recursion_limit = "1024"]
pub mod acp_bridge;
pub mod acp_client;
pub mod acp_runtime;
pub mod acp_session_manager;
pub mod acp_types;
pub mod api_chat;
pub mod app;
pub mod context;
pub mod document_buffer_store;
pub mod editor_actor;
pub mod error;
pub mod evidence_parse;
pub mod hermes_tools;
pub mod mnote_agent_tools;
pub mod local_folder_watcher_registry;
pub mod middleware;
pub mod page_aggregate;
@@ -29,7 +23,7 @@ pub use app::{build_app, AppConfig, AppState};
pub(crate) mod test_support {
use std::sync::{Mutex, OnceLock};
pub(crate) fn hermes_env_lock() -> &'static Mutex<()> {
pub(crate) fn agent_env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
@@ -1,11 +1,30 @@
use crate::context::RequestContext;
use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token};
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
pub async fn inject_request_context(mut request: Request, next: Next) -> Response {
let context =
let mut context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
// 12-3 E2: Authorization Bearer mnext1.* → actor (when cookie/header actor is anonymous)
if context.auth.actor_id.trim() == "anonymous" || context.auth.actor_id.trim().is_empty() {
if let Some(token) = bearer_mnext1(context.auth.authorization.as_deref()) {
if let Ok(claims) = verify_extension_token(token) {
context.auth.actor_id = claims.actor;
if context.auth.actor_type.trim().is_empty()
|| context.auth.actor_type.trim() == "anonymous"
{
context.auth.actor_type = "user".into();
}
if context.auth.session_id.is_none() {
context.auth.session_id = Some(format!("ext:{}", claims.jti));
}
}
}
}
request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await;
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
use crate::routes::ensure_local_workspace_access;
use bridge_runtime::{
@@ -179,8 +179,8 @@ async fn create_artifact_node(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "agent".into(),
client: "mnote-agent-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
@@ -208,7 +208,7 @@ async fn create_artifact_node(
"artifact": {
"kind": node_type,
"sourceDocumentId": document_id,
"source": "hermes",
"source": "agent",
"sessionId": input.session_id,
"runId": input.run_id,
"toolCallId": input.tool_call_id,
@@ -221,7 +221,7 @@ async fn create_artifact_node(
}),
preflight_data: None,
reason: Some(tool_name.into()),
refs: vec![tool_name.into(), "hermes-tool-call".into()],
refs: vec![tool_name.into(), "agent-tool-call".into()],
dry_run: false,
validate_only: false,
};
@@ -276,5 +276,5 @@ fn sanitize_local_artifact_file_name(value: &str) -> String {
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
@@ -1,11 +1,11 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::doc::{
use crate::mnote_agent_tools::doc::{
aggregate_value, block_id_of, block_not_found, block_projection_blocks, find_block,
required_arg,
};
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
use bridge_runtime::{
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
@@ -693,7 +693,7 @@ pub(crate) fn ensure_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
fn ensure_leaf_block(
@@ -1128,8 +1128,8 @@ async fn execute_page_body_save(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "agent".into(),
client: "mnote-agent-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
@@ -1142,8 +1142,8 @@ async fn execute_page_body_save(
}),
payload,
preflight_data: None,
reason: Some("Hermes block tool page.body.save".into()),
refs: vec!["page.body.save".into(), "hermes-block-tool-call".into()],
reason: Some("agent block tool page.body.save".into()),
refs: vec!["page.body.save".into(), "agent-block-tool-call".into()],
dry_run: false,
validate_only: false,
};
@@ -1199,8 +1199,8 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "agent".into(),
client: "mnote-agent-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
@@ -1213,10 +1213,10 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
}),
payload,
preflight_data: None,
reason: Some("Hermes batch block tool page.body.save".into()),
reason: Some("agent batch block tool page.body.save".into()),
refs: vec![
"page.body.save".into(),
"hermes-batch-block-tool-call".into(),
"agent-batch-block-tool-call".into(),
],
dry_run: false,
validate_only: false,
@@ -1480,7 +1480,7 @@ mod tests {
fn ensure_write_contract_rejects_read_only_ai_scope() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools".parse().expect("uri"),
&"/api/mnote/tools".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input = ToolCallInput {
@@ -1521,13 +1521,13 @@ mod tests {
"attrs": {},
"payload": {
"marks": [],
"text": "Hermes 插件替换第二段",
"text": "agent 插件替换第二段",
"type": "text"
}
}
]);
assert_eq!(content_to_text(&value), "Hermes 插件替换第二段");
assert_eq!(content_to_text(&value), "agent 插件替换第二段");
}
#[test]
@@ -1540,7 +1540,7 @@ mod tests {
"attrs": {},
"payload": {
"marks": [],
"text": "Hermes 插件插入段",
"text": "agent 插件插入段",
"type": "text"
}
}
@@ -1548,6 +1548,6 @@ mod tests {
}
]);
assert_eq!(content_to_text(&value), "Hermes 插件插入段");
assert_eq!(content_to_text(&value), "agent 插件插入段");
}
}
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use crate::mnote_agent_tools::{doc, ToolCallInput};
use serde_json::{json, Value};
pub async fn context_snapshot(
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::web_shell::build_page_aggregate_snapshot;
use axum::http::StatusCode;
use serde_json::{json, Value};
@@ -346,7 +346,7 @@ pub async fn plan_update(
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入计划型 mnote Hermes tool 必须携带 idempotencyKey",
"写入计划型 mnote agent tool 必须携带 idempotencyKey",
)
.with_context(context));
}
@@ -1492,7 +1492,7 @@ pub async fn doc_markdown_edit(
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
let is_local_workspace =
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
crate::hermes_tools::block::ensure_write_contract(context, input)?;
crate::mnote_agent_tools::block::ensure_write_contract(context, input)?;
// 1. 读取当前文档内容(markdown 形式)
let (current_md, source) = if is_local_file {
@@ -1650,7 +1650,7 @@ pub async fn doc_markdown_edit(
match aggregate_value(state, context, input).await {
Ok(agg) => {
let blocks = block_projection_blocks(&agg);
let original_content = crate::hermes_tools::block::current_body_content(&agg);
let original_content = crate::mnote_agent_tools::block::current_body_content(&agg);
(agg, blocks, original_content)
}
Err(_) if use_full_content.is_some() => {
@@ -1756,8 +1756,8 @@ pub async fn doc_markdown_edit(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "mnote-hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "mnote-agent".into(),
client: "mnote-agent-plugin".into(),
source_kind,
root_uri,
workspace_id: None,
@@ -1771,7 +1771,7 @@ pub async fn doc_markdown_edit(
payload,
preflight_data: None,
reason: Some("mnote.doc.markdown_edit (7-27)".into()),
refs: vec!["page.body.save".into(), "mnote-hermes-tool-call".into()],
refs: vec!["page.body.save".into(), "mnote-agent-tool-call".into()],
dry_run: false,
validate_only: false,
};
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{ensure_write_authorized, ToolCallInput};
use crate::mnote_agent_tools::{ensure_write_authorized, ToolCallInput};
use crate::routes;
use axum::http::StatusCode;
use serde::Deserialize;
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::knowledge_rag::{
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSearchRequest,
KnowledgeRagSectionContextRequest, KnowledgeRagStatusQuery,
@@ -1,8 +1,8 @@
use super::skill;
use serde_json::{json, Value};
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1";
pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1";
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.agent_tool_manifest.v1";
pub const TOOL_SCHEMA_VERSION: &str = "mnote.agent_tool.v1";
pub fn manifest() -> Value {
let tools = annotate_tools_with_capabilities(assemble_all_tools());
@@ -177,7 +177,7 @@ impl ToolCallInput {
}
}
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 hermes_tools 写入守卫。
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 agent tools 写入守卫。
///
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
@@ -191,7 +191,7 @@ pub struct CommandContextBridge {
pub ai_can_write: bool,
}
/// 统一的 hermes_tools 写入守卫。检查:
/// 统一的 agent tools 写入守卫。检查:
///
/// - `idempotencyKey` 必须存在
/// - `dryRun` 必须显式携带
@@ -207,14 +207,14 @@ pub fn ensure_write_authorized(
if !input.has_idempotency_key() {
return Err(WebError::bad_request_code(
"mnote_tool_idempotency_required",
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
"写入型 mnote agent tool 必须携带 idempotencyKey",
)
.with_context(context));
}
if input.dry_run.is_none() {
return Err(WebError::bad_request_code(
"mnote_tool_dry_run_required",
"写入型 mnote Hermes tool 必须显式携带 dryRun",
"写入型 mnote agent tool 必须显式携带 dryRun",
)
.with_context(context));
}
@@ -256,7 +256,7 @@ mod tests {
fn context() -> RequestContext {
RequestContext::from_http_parts(
&Method::POST,
&"/api/hermes/tools".parse().expect("uri"),
&"/api/mnote/tools".parse().expect("uri"),
&HeaderMap::new(),
)
}
@@ -1,6 +1,6 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{ensure_write_authorized, ToolCallInput};
use crate::mnote_agent_tools::{ensure_write_authorized, ToolCallInput};
use crate::routes::onlyoffice_bridge::{self, BridgeResultWire, BridgeRunError};
use axum::http::StatusCode;
use serde_json::{json, Value};
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
use crate::routes::web_shell::build_page_aggregate_snapshot;
use bridge_runtime::{
@@ -18,7 +18,7 @@ pub async fn page_get(
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
.with_context(context)
})?;
crate::hermes_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
crate::mnote_agent_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?;
let workspace_id = input.effective_workspace_id();
let source_kind = input.effective_source_kind();
let root_uri = input.effective_root_uri();
@@ -72,7 +72,7 @@ pub async fn page_get(
}
fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
pub async fn page_save(
@@ -287,8 +287,8 @@ async fn page_command(
.or_else(|| context.auth.session_id.clone()),
},
source: RuntimeSourceWire {
channel: "hermes".into(),
client: "mnote-hermes-plugin".into(),
channel: "agent".into(),
client: "mnote-agent-plugin".into(),
source_kind: None,
root_uri: None,
workspace_id: workspace_id.clone(),
@@ -301,8 +301,8 @@ async fn page_command(
}),
payload,
preflight_data: None,
reason: Some(format!("Hermes tool {command_name}")),
refs: vec![command_name.into(), "hermes-tool-call".into()],
reason: Some(format!("agent tool {command_name}")),
refs: vec![command_name.into(), "agent-tool-call".into()],
dry_run: false,
validate_only: false,
};
@@ -1,6 +1,6 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use axum::http::StatusCode;
use serde_json::{json, Value};
use std::collections::HashSet;
@@ -468,7 +468,7 @@ fn ensure_resource_write_contract(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<(), WebError> {
crate::hermes_tools::ensure_write_authorized(context, input)
crate::mnote_agent_tools::ensure_write_authorized(context, input)
}
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
@@ -1,6 +1,6 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use axum::http::StatusCode;
use serde_json::{json, Value};
@@ -25,7 +25,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "当前页读取",
description: "仅在任务需要当前 MNote Markdown 页面内容时读取当前页。",
category: "mnote",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: true,
requires_context_refs: &["current_page"],
tool_names: &[
@@ -40,7 +40,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "知识库问答",
description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG。",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
@@ -58,7 +58,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "本地文件编辑",
description: "在 MNote 授权目录内读取和修改本地 Markdown 文件。",
category: "file",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder"],
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
@@ -69,7 +69,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "ONLYOFFICE 实时编辑",
description: "操作当前已打开的 ONLYOFFICE Word、Excel、PPT 编辑会话。",
category: "office",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: false,
requires_context_refs: &["onlyoffice"],
tool_names: &[
@@ -116,7 +116,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "思维导图",
description: "读取、更新、总结或创建 MNote 思维导图资源。",
category: "resource",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder", "resource"],
tool_names: &[
@@ -133,7 +133,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "密码箱 / AI 密码本",
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
category: "security",
agent_ids: &["hermes", "reasonix"],
agent_ids: &["pi"],
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
@@ -153,7 +153,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
title: "纯聊天",
description: "只进行对话回复,不读取或写入 MNote 页面、文件上下文。",
category: "chat",
agent_ids: &["chat_only", "hermes", "reasonix"],
agent_ids: &["chat_only", "pi"],
read_only: true,
requires_context_refs: &[],
tool_names: &[],
@@ -306,18 +306,18 @@ mod tests {
#[test]
fn skill_lookup_rejects_unknown_or_agent_mismatch() {
assert!(find_skill("mnote-current-page", Some("reasonix")).is_some());
assert!(find_skill("mnote-current-page", Some("pi")).is_some());
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
assert!(find_skill("missing", Some("reasonix")).is_none());
assert!(find_skill("missing", Some("pi")).is_none());
}
#[test]
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
let pi_agent_skills = skill_summaries_for_agent(Some("pi"));
let skill = pi_agent_skills
.iter()
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
.expect("reasonix should see live ONLYOFFICE skill");
.expect("pi should see live ONLYOFFICE skill");
assert_eq!(skill["readOnly"], false);
assert_eq!(
skill["toolNames"]
@@ -363,11 +363,11 @@ mod tests {
#[test]
fn skill_registry_exposes_mindmap_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
let pi_agent_skills = skill_summaries_for_agent(Some("pi"));
let skill = pi_agent_skills
.iter()
.find(|skill| skill["id"] == "mnote-mindmap")
.expect("reasonix should see mindmap skill");
.expect("pi should see mindmap skill");
assert_eq!(skill["readOnly"], false);
assert!(skill["requiresContextRefs"]
.as_array()
@@ -383,12 +383,11 @@ mod tests {
#[test]
fn skill_registry_exposes_vault_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
let skill = reasonix_skills
let pi_skills = skill_summaries_for_agent(Some("pi"));
let skill = pi_skills
.iter()
.find(|skill| skill["id"] == "mnote-vault")
.expect("reasonix should see vault skill");
.expect("pi should see vault skill");
assert_eq!(skill["readOnly"], true);
assert_eq!(skill["category"], "security");
assert!(skill["toolNames"]
@@ -396,12 +395,9 @@ mod tests {
.expect("tool names")
.iter()
.any(|name| name == "mnote.vault.resolve"));
assert!(hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-vault"));
assert!(find_skill("mnote-vault", Some("chat_only")).is_none());
let body = find_skill("mnote-vault", Some("hermes"))
.expect("hermes can read vault skill")
let body = find_skill("mnote-vault", Some("pi"))
.expect("pi can read vault skill")
.content;
assert!(body.contains(".mnote/vault"));
assert!(body.contains("共享到 AI") || body.contains("AI 密码本"));
@@ -409,28 +405,28 @@ mod tests {
#[test]
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
assert!(!hermes_skills
let pi_skills = skill_summaries_for_agent(Some("pi"));
assert!(!pi_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-index"));
let skill = hermes_skills
let skill = pi_skills
.iter()
.find(|skill| skill["id"] == "mnote-knowledge-rag")
.expect("hermes should see LightRAG skill");
.expect("pi should see LightRAG skill");
assert_eq!(skill["readOnly"], true);
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.knowledge_rag.query"));
assert!(!hermes_skills
assert!(!pi_skills
.iter()
.any(|skill| skill["id"] == "mnote-document-evidence"));
}
#[test]
fn skill_read_maps_document_evidence_alias_to_lightrag() {
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
let skill = find_skill("mnote-document-evidence", Some("pi"))
.expect("compat alias should resolve");
assert_eq!(skill.id, "mnote-knowledge-rag");
}
@@ -439,14 +435,14 @@ mod tests {
async fn skill_read_returns_mindmap_skill_content() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools/execute".parse().expect("uri"),
&"/api/mnote/tools/call".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input: ToolCallInput = serde_json::from_value(json!({
"toolName": "mnote.skill.read",
"args": {
"skillId": "mnote-mindmap",
"agentId": "reasonix"
"agentId": "pi"
}
}))
.expect("input");
@@ -1385,7 +1385,7 @@ fn default_skill_registry() -> HashMap<String, SkillConfig> {
skill_config(
"Global Search",
"聚合本机/网页搜索线索,适合研究型查询入口。",
"/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md",
"/home/lix/.mnote/agent-profiles/lite/skills/global-search/SKILL.md",
"medium",
&["network:search"],
),
@@ -3611,7 +3611,7 @@ mod tests {
#[test]
fn admin_user_display_role_includes_access_policy_admins() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
@@ -168,7 +168,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+3 -7
View File
@@ -30,7 +30,7 @@ pub async fn next_ai_agent_run(
StatusCode::GONE,
"legacy_ai_agent_run_retired",
format!(
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Hermes client proxy 与 mnote Hermes plugin"
"旧 /api/ai-agent/run 页面 AI 主链已退场,provider={provider} 不再静默降级;请使用 Pi Lab 与 /api/mnote/tools"
),
)
.with_context(&context)
@@ -74,7 +74,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -114,7 +113,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -156,7 +154,7 @@ mod tests {
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("legacy_ai_agent_run_retired"));
assert!(text.contains("Hermes client proxy"));
assert!(text.contains("Pi Lab"));
}
#[tokio::test]
@@ -171,7 +169,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -210,7 +207,7 @@ mod tests {
}
#[tokio::test]
async fn explicit_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() {
async fn explicit_retired_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() {
let next_app = axum::Router::new().route(
"/api/ai-agent/run",
axum::routing::post(|| async move {
@@ -240,7 +237,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+3 -4
View File
@@ -523,7 +523,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: false,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -601,7 +600,7 @@ mod tests {
"session_id": "test-session-1",
"run_id": "test-run-1",
"profile": "test-profile",
"acp_runtime": "reasonix",
"acp_runtime": "pi",
"status": "running",
"events": [{
"eventType": "message",
@@ -616,7 +615,7 @@ mod tests {
assert_eq!(payload["results"][0]["kind"], "seedAiRuntime");
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "pi");
assert_eq!(payload["results"][0]["run"]["status"], "running");
assert_eq!(payload["results"][0]["events"].as_array().unwrap().len(), 1);
@@ -636,7 +635,7 @@ mod tests {
assert!(payload["results"][0]["run"].is_object());
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
assert_eq!(payload["results"][0]["run"]["profile"], "test-profile");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "reasonix");
assert_eq!(payload["results"][0]["run"]["acp_runtime"], "pi");
assert_eq!(payload["results"][0]["run"]["status"], "running");
}
@@ -1197,7 +1197,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1780,7 +1780,6 @@ mod tests {
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -389,7 +389,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+6 -8
View File
@@ -2998,7 +2998,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url,
convex_admin_key: None,
@@ -3418,7 +3417,7 @@ mod tests {
#[tokio::test]
async fn root_entry_renders_local_first_landing_without_convex() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let base = temp_root("mnote-root-local-first-landing");
@@ -3521,7 +3520,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3589,7 +3587,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3897,7 +3894,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -4039,11 +4035,13 @@ mod tests {
&root_uri,
)
.expect("init local workspace");
// pageId 触发 reveallazy PageTree 在 scope 内展开 active 文档父链。
let page_id = "local-md:design~2F05-editor-mainline~2FTarget.md";
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri(format!(
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design"
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree&fileTreeScope=design&pageId={page_id}"
))
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
@@ -4181,7 +4179,7 @@ mod tests {
#[tokio::test]
async fn root_entry_initializes_default_local_workspace_page() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let base = temp_root("mnote-root-default-local-workspace");
@@ -4239,7 +4237,7 @@ mod tests {
#[tokio::test]
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let response = app_with_config("http://127.0.0.1:3100".into(), false)
-297
View File
@@ -1,297 +0,0 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
runtime_input_requests_result, RuntimeInput,
};
use serde::Serialize;
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HermesHealthResponse {
pub ok: bool,
pub service: String,
pub bridge: &'static str,
pub request_id: String,
pub trace_id: String,
}
pub async fn health(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Json<HermesHealthResponse> {
Json(HermesHealthResponse {
ok: true,
service: state.config().service_name.clone(),
bridge: "hermes",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
})
}
pub async fn bridge_runtime(
Extension(context): Extension<RequestContext>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let session_id = format!("hermes_{}", context.trace.request_id);
let runtime_input = match serde_json::from_value::<RuntimeInput>(payload.clone()) {
Ok(runtime_input) => runtime_input,
Err(_) => {
return Ok((
StatusCode::OK,
stamp_ai_bridge_headers(),
Json(json!({
"ok": true,
"bridge": "hermes_session",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"structuredWrite": {
"owner": "rust-web-hermes",
"allowedCommands": [
"page.body.save",
"tree.node.create",
"kernel.edge.attach"
]
},
"compatPayload": payload,
})),
));
}
};
let payload = if runtime_input_requests_result(&runtime_input) {
match execute_runtime_query(runtime_input) {
Ok(result) => json!({
"ok": true,
"bridge": "hermes_runtime_result",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"result": result,
}),
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
} else {
match execute_runtime_input(runtime_input) {
Ok(plan) => {
let success = build_success_response(plan);
json!({
"ok": success.ok,
"bridge": "hermes_runtime_plan",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id),
"canonicalRoute": "/api/hermes/bridge",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"contract": ai_bridge_contract(&session_id),
"plan": success.plan,
})
}
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
};
Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload)))
}
fn ai_bridge_contract(session_id: &str) -> Value {
json!({
"schema": "mnote.ai_bridge.v1",
"owner": "mnote-web",
"bridge": "hermes",
"sessionId": session_id,
"eventStreamEndpoint": format!("/api/hermes/events/{session_id}"),
"canonicalRoute": "/api/hermes/bridge",
"sessionOwner": "rust-web-hermes",
"toolEventOwner": "rust-web-hermes",
"clientActionOwner": "rust-web-hermes",
"structuredWriteOwner": "rust-web-hermes"
})
}
fn stamp_ai_bridge_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
if let Ok(name) = HeaderName::from_lowercase(HEADER_AI_BRIDGE_OWNER.as_bytes()) {
headers.insert(name, HeaderValue::from_static("rust-web-hermes"));
}
headers
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::{json, Value};
use tower::util::ServiceExt;
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
public_bind_addr: "127.0.0.1:3000".into(),
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
enable_legacy_next_compat: true,
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
mutation_fixtures_json: None,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn ai_bridge_route_returns_hermes_owner_contract() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/bridge")
.header("content-type", "application/json")
.body(Body::from(
json!({
"kind": "tool",
"context": {
"deploymentId": null,
"projectId": null,
"workspaceId": "ws_demo",
"requestId": "req_1",
"traceId": "trace_1",
"actor": {
"actorType": "user",
"actorId": "user_1",
"sessionId": null
},
"source": {
"channel": "rust-web",
"client": "mnote-web"
},
"tenantId": null,
"authToken": null,
"idempotencyKey": null,
"validateOnly": false,
"dryRun": false
},
"tool": {
"tool": "search_web",
"kind": "query",
"mode": "plan",
"argsJson": {"query": "Rust Web"},
"target": null,
"reason": "owner gate",
"refs": []
},
"data": null
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
assert_eq!(
response
.headers()
.get("x-mnote-ai-bridge-owner")
.and_then(|value| value.to_str().ok()),
Some("rust-web-hermes")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["contract"]["schema"], "mnote.ai_bridge.v1");
assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes");
assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert!(payload["eventStreamEndpoint"]
.as_str()
.unwrap_or_default()
.contains("/api/hermes/events/"));
}
#[tokio::test]
async fn ai_bridge_accepts_legacy_intent_payload_as_hermes_session() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/bridge")
.header("content-type", "application/json")
.body(Body::from(
json!({
"stream": true,
"scope": "document",
"messages": [{"role": "user", "content": "生成摘要"}],
"context": {"documentId": "doc_1"}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["bridge"], "hermes_session");
assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge");
assert_eq!(
payload["contract"]["structuredWriteOwner"],
"rust-web-hermes"
);
}
}
File diff suppressed because it is too large Load Diff
@@ -299,7 +299,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -505,7 +505,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -144,12 +144,24 @@ fn local_folder_metadata_fingerprints(root: &Path) -> [u64; 4] {
}
fn invalidate_local_folder_metadata_cache(root: &Path) {
let key = root.to_string_lossy().to_string();
// 与 local_folder_watch_revision / metadata 写入侧一致:优先用 canonicalize 后的 key。
let key = root
.canonicalize()
.unwrap_or_else(|_| root.to_path_buf())
.to_string_lossy()
.to_string();
let raw_key = root.to_string_lossy().to_string();
if let Ok(mut cache) = local_folder_metadata_cache().lock() {
cache.remove(&key);
if raw_key != key {
cache.remove(&raw_key);
}
}
if let Ok(mut cache) = local_folder_watch_revision_cache().lock() {
cache.remove(&key);
if raw_key != key {
cache.remove(&raw_key);
}
}
}
@@ -10703,7 +10715,7 @@ mod tests {
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
crate::test_support::hermes_env_lock()
crate::test_support::agent_env_lock()
}
fn temp_root(name: &str) -> std::path::PathBuf {
@@ -10735,7 +10747,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -10926,6 +10937,7 @@ mod tests {
.expect("write md");
let root_uri = format!("file://{}", root.display());
// 根快照 lazy:仅一层;根级 md 直接可见。
let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot");
let first_json = first.projection.to_string();
assert!(first_json.contains("local-md:page.md"));
@@ -10934,9 +10946,14 @@ mod tests {
std::fs::create_dir_all(root.join("docs")).expect("create docs");
std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md"))
.expect("move md");
let second = load_local_folder_page_tree_snapshot(&root_uri).expect("second snapshot");
// 嵌套页需 scope 到父目录才能在 lazy PageTree 中看到。
let second =
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("second snapshot");
let second_json = second.projection.to_string();
assert!(second_json.contains("local-md:docs~2Frenamed.md"));
assert!(
second_json.contains("local-md:docs~2Frenamed.md"),
"second_json missing expected id; got: {second_json}"
);
assert!(!second_json.contains("local-mdid:stable-frontmatter-id"));
assert!(second_json.contains("renamed.md"));
@@ -10957,9 +10974,14 @@ mod tests {
.expect("write page ids");
let root_uri = format!("file://{}", root.display());
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("snapshot");
// lazy:嵌套 page 在 parent scope 中按 path 派生 id,忽略 page-ids.json 稳定 id。
let snapshot =
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("snapshot");
let html_json = snapshot.projection.to_string();
assert!(html_json.contains("local-md:docs~2Fpage.md"));
assert!(
html_json.contains("local-md:docs~2Fpage.md"),
"missing path id; got: {html_json}"
);
assert!(!html_json.contains("local-mdid:stable-from-page-ids"));
assert!(!html_json.contains("page-ids.json"));
@@ -15285,13 +15307,18 @@ fn main() {}
);
}
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
// lazy 根快照只有 docs 文件夹;真实 page 在 docs scope 内。
let page_tree =
load_local_folder_page_tree_scope_snapshot(&root_uri, "docs").expect("page tree");
let page_items = page_tree.projection["items"]
.as_array()
.expect("page items");
assert!(page_items
assert!(
page_items
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")));
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")),
"Page.md 应出现在 docs scope PageTree"
);
assert!(!page_items.iter().any(|item| item["documentId"].as_str()
== Some("local-md:docs~2FPage.ocr~2Fphoto.png.ocr.md")));
@@ -15525,14 +15552,23 @@ fn main() {}
let first = local_folder_watch_revision(&root_uri).expect("first revision");
// Mutate tree immediately; TTL should still serve previous revision.
std::fs::write(root.join("docs").join("page.md"), "# Two\n").expect("update md");
// 改长度 + 增文件,避免仅改同长内容时 mtime 精度导致 hash 不变。
std::fs::write(
root.join("docs").join("page.md"),
"# Two — longer body to change len fingerprint\n",
)
.expect("update md");
std::fs::write(root.join("docs").join("extra.md"), "# Extra\n").expect("add md");
let cached = local_folder_watch_revision(&root_uri).expect("cached revision");
assert_eq!(first.revision, cached.revision);
// Explicit invalidation path (same as metadata writes) must force recompute.
invalidate_local_folder_metadata_cache(&root);
let after_invalidate = local_folder_watch_revision(&root_uri).expect("fresh revision");
assert_ne!(first.revision, after_invalidate.revision);
assert_ne!(
first.revision, after_invalidate.revision,
"invalidate 后应看到 entry_count/len 变化"
);
set_local_folder_watch_revision_cache_ttl_ms_for_test(2_000);
let _ = std::fs::remove_dir_all(&root);
@@ -5170,7 +5170,7 @@ mod tests {
let workspace_id = "local-ws-evidence-sqlite";
fs::write(
root.join("README.md"),
"# Home\nIntro body.\n## Evidence Section\nEvidenceToken root body.\nAsk @Reasonix for citation.\n",
"# Home\nIntro body.\n## Evidence Section\nEvidenceToken root body.\nAsk @AtlasNote for citation.\n",
)
.expect("write home");
fs::write(
@@ -5239,7 +5239,7 @@ mod tests {
assert!(section_count >= 3);
let mention_source_block: String = connection
.query_row(
"SELECT source_block_id FROM evidence_edge WHERE edge_type = 'mentions' AND to_id = 'entity:Reasonix'",
"SELECT source_block_id FROM evidence_edge WHERE edge_type = 'mentions' AND to_id = 'entity:AtlasNote'",
[],
|row| row.get(0),
)
@@ -5253,7 +5253,7 @@ mod tests {
)
.expect("mention source locator");
assert!(mention_locator.contains("mnote.evidence_locator.v1"));
let graph_results = query_evidence_graph_results(&root, "Reasonix", None, 10)
let graph_results = query_evidence_graph_results(&root, "AtlasNote", None, 10)
.expect("graph query")
.expect("sqlite exists");
let mention_result = graph_results
@@ -487,7 +487,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -353,7 +353,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -422,7 +421,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -508,7 +506,7 @@ mod tests {
#[test]
fn mindmap_standalone_bootstrap_propagates_dev_hot_to_island_runtime() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
+24 -332
View File
@@ -9,9 +9,7 @@ mod editor;
pub(crate) mod evidence;
mod gateway;
mod health;
mod hermes;
mod hermes_client;
mod hermes_tools;
mod mnote_tools;
mod kernel;
pub(crate) mod knowledge_rag;
mod local_folder_events;
@@ -26,8 +24,6 @@ mod mindmap_shell;
pub(crate) mod navigation_recent;
mod onlyoffice;
pub(crate) mod onlyoffice_bridge;
mod page_ai_board;
mod page_ai_opencode;
mod page_ai_pi;
mod page_ai_workflow;
mod query_support;
@@ -43,6 +39,7 @@ mod tree_view_state;
mod ui_debug;
pub(crate) mod ui_preferences;
mod vault;
pub(crate) mod vault_extension_token;
mod vault_path;
mod vault_store;
mod vault_transport;
@@ -72,7 +69,6 @@ use axum::routing::{any, delete, get, post, put};
use axum::Router;
pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone();
let enable_debug_shell_routes = state.config().enable_debug_shell_routes;
let mut router = Router::new()
@@ -248,42 +244,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
get(web_shell::sidebar_attachment_open_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/agent-stream-event-router.js",
get(web_shell::agent_stream_event_router_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
get(web_shell::sidebar_page_ai_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
get(web_shell::sidebar_page_ai_markdown_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
get(web_shell::sidebar_page_ai_render_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
get(web_shell::sidebar_page_ai_permission_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
get(web_shell::sidebar_page_ai_profile_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
get(web_shell::sidebar_page_ai_session_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
get(web_shell::sidebar_page_ai_skill_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
get(web_shell::sidebar_page_ai_target_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
get(web_shell::sidebar_page_ai_pi_lab_runtime_asset),
@@ -411,6 +375,18 @@ pub fn build_router(state: AppState) -> Router {
"/api/vault/items/{id}/unshare-from-ai",
post(vault::unshare_from_ai),
)
.route(
"/api/vault/items/{id}/session",
put(vault::put_item_session),
)
.route(
"/api/vault/extension/token",
post(vault::issue_extension_token),
)
.route(
"/api/vault/extension/token/revoke",
post(vault::revoke_extension_token),
)
.route("/api/vault/ai/list", get(vault::list_ai))
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
.route(
@@ -463,140 +439,6 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/auth/whoami", get(session::session))
.route("/api/auth/mnote-web-token", get(session::session))
.route("/api/auth/session/refresh", post(session::refresh_session))
.route(
"/api/ai/agent-profiles",
get(hermes_client::list_agent_profiles),
)
.route(
"/api/page-ai/opencode/status",
get(page_ai_opencode::status),
)
.route(
"/api/page-ai/opencode/session",
post(page_ai_opencode::bind_session),
)
.route(
"/api/page-ai/opencode/sessions",
get(page_ai_opencode::sessions),
)
.route("/api/page-ai/opencode/abort", post(page_ai_opencode::abort))
.route("/api/page-ai/opencode/todo", get(page_ai_opencode::todo))
.route("/api/page-ai/opencode/diff", get(page_ai_opencode::diff))
.route(
"/api/page-ai/opencode/messages",
get(page_ai_opencode::messages),
)
.route(
"/api/page-ai/opencode/prompt",
post(page_ai_opencode::prompt),
)
.route(
"/api/page-ai/opencode/permissions",
get(page_ai_opencode::permissions),
)
.route(
"/api/page-ai/opencode/permission/reply",
post(page_ai_opencode::reply_permission),
)
.route(
"/api/page-ai/opencode/events",
get(page_ai_opencode::events),
)
.route("/page-ai/opencode", any(page_ai_opencode::proxy_root))
.route(
"/page-ai/opencode/assets/{*path}",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-96x96-v3.png",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-v3.svg",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-v3.ico",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/apple-touch-icon-v3.png",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/site.webmanifest",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/social-share.png",
any(page_ai_opencode::proxy_assets),
)
.route("/page-ai/opencode/{*path}", any(page_ai_opencode::proxy))
.route("/assets/{*path}", any(page_ai_opencode::proxy_assets))
.route("/global/{*path}", any(page_ai_opencode::proxy_assets))
.route(
"/favicon-96x96-v3.png",
any(page_ai_opencode::proxy_current_path),
)
.route("/favicon-v3.svg", any(page_ai_opencode::proxy_current_path))
.route("/favicon-v3.ico", any(page_ai_opencode::proxy_current_path))
.route(
"/apple-touch-icon-v3.png",
any(page_ai_opencode::proxy_current_path),
)
.route(
"/site.webmanifest",
any(page_ai_opencode::proxy_current_path),
)
.route(
"/social-share.png",
any(page_ai_opencode::proxy_current_path),
)
.route("/provider", any(page_ai_opencode::proxy_current_path))
.route("/path", any(page_ai_opencode::proxy_current_path))
.route("/project", any(page_ai_opencode::proxy_current_path))
.route(
"/project/{*path}",
any(page_ai_opencode::proxy_current_path),
)
.route("/lsp", any(page_ai_opencode::proxy_current_path))
.route("/command", any(page_ai_opencode::proxy_current_path))
.route("/mcp", any(page_ai_opencode::proxy_current_path))
.route("/agent", any(page_ai_opencode::proxy_current_path))
.route("/config", any(page_ai_opencode::proxy_current_path))
.route("/vcs", any(page_ai_opencode::proxy_current_path))
.route("/permission", any(page_ai_opencode::proxy_current_path))
.route("/question", any(page_ai_opencode::proxy_current_path))
.route("/event", any(page_ai_opencode::proxy_current_path))
.route("/session", any(page_ai_opencode::proxy_current_path))
.route(
"/session/{*path}",
any(page_ai_opencode::proxy_current_path),
)
.route("/new-session", any(page_ai_opencode::proxy_current_path))
.route(
"/{opencode_dir}/session",
any(page_ai_opencode::proxy_current_path),
)
.route(
"/{opencode_dir}/session/{*path}",
any(page_ai_opencode::proxy_current_path),
)
.route("/api/page-ai/board/status", get(page_ai_board::status))
.route("/api/page-ai/board/workers", get(page_ai_board::workers))
.route(
"/api/page-ai/board/workflows",
get(page_ai_board::workflows),
)
.route("/api/page-ai/board/runs", post(page_ai_board::create_run))
.route(
"/api/page-ai/board/runs/{run_id}",
get(page_ai_board::get_run),
)
.route(
"/api/page-ai/board/runs/{run_id}/cancel",
post(page_ai_board::cancel_run),
)
.route("/api/page-ai/pi/status", get(page_ai_pi::status))
.route("/api/page-ai/pi/bootstrap", post(page_ai_pi::bootstrap))
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
@@ -820,63 +662,6 @@ pub fn build_router(state: AppState) -> Router {
"/api/onlyoffice/bridge/capabilities",
get(onlyoffice_bridge::capabilities),
)
.route(
"/api/page-ai/agents/descriptors",
get(hermes_client::list_agent_descriptors),
)
.route("/api/page-ai/runs", post(hermes_client::create_page_ai_run))
.route(
"/api/page-ai/runs/{host_run_id}",
get(hermes_client::get_page_ai_run),
)
.route(
"/api/page-ai/runs/{host_run_id}/events",
get(hermes_client::list_page_ai_run_events),
)
.route(
"/api/page-ai/sessions/{session_id}/active-run",
get(hermes_client::get_page_ai_session_active_run),
)
.route(
"/api/page-ai/runtime/status",
get(hermes_client::get_page_ai_runtime_status),
)
.route(
"/api/page-ai/runtime/reset",
post(hermes_client::reset_page_ai_runtime),
)
.route(
"/api/page-ai/sessions",
get(hermes_client::list_sessions).post(hermes_client::create_session),
)
.route(
"/api/page-ai/sessions/search",
get(hermes_client::search_sessions),
)
.route(
"/api/page-ai/sessions/{session_id}",
get(hermes_client::get_session).delete(hermes_client::delete_session),
)
.route(
"/api/page-ai/sessions/{session_id}/resume",
post(hermes_client::resume_session),
)
.route(
"/api/page-ai/sessions/{session_id}/rename",
post(hermes_client::rename_session),
)
.route(
"/api/page-ai/sessions/{session_id}/export",
get(hermes_client::export_session),
)
.route(
"/api/page-ai/sessions/{session_id}/auto-title",
post(hermes_client::auto_title_session),
)
.route(
"/api/page-ai/sessions/{session_id}/queue/{queue_id}",
delete(hermes_client::cancel_queued_run),
)
.route(
"/api/onlyoffice/bridge/commands",
post(onlyoffice_bridge::enqueue_command),
@@ -1018,94 +803,12 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/tree/events", get(sse::tree_events))
.route("/api/stream/events", get(sse::events))
.route("/api/realtime/ws", get(ws::socket))
.nest(
&hermes_base_path,
Router::new()
.route("/health", get(hermes::health))
.route("/bridge", post(hermes::bridge_runtime))
.route(
"/client/sessions",
get(hermes_client::list_sessions).post(hermes_client::create_session),
)
.route(
"/client/sessions/search",
get(hermes_client::search_sessions),
)
.route(
"/client/sessions/{session_id}",
get(hermes_client::get_session).delete(hermes_client::delete_session),
)
.route(
"/client/sessions/{session_id}/resume",
post(hermes_client::resume_session),
)
.route(
"/client/sessions/{session_id}/rename",
post(hermes_client::rename_session),
)
.route(
"/client/sessions/{session_id}/export",
get(hermes_client::export_session),
)
.route(
"/client/sessions/{session_id}/auto-title",
post(hermes_client::auto_title_session),
)
.route("/client/gateway/health", get(hermes_client::gateway_health))
.route("/client/profiles", get(hermes_client::list_profiles))
.route(
"/client/profiles/active",
put(hermes_client::switch_active_profile),
)
.route(
"/client/profiles/{profile_name}",
get(hermes_client::get_profile),
)
.route(
"/client/profile-memory",
get(hermes_client::get_profile_memory).post(hermes_client::save_profile_memory),
)
.route("/client/skills", get(hermes_client::list_skills))
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
.route(
"/client/capabilities",
get(hermes_client::list_capabilities),
)
.route(
"/client/capabilities/toggle",
put(hermes_client::toggle_capability),
)
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
.route("/client/runs", post(hermes_client::create_run))
.route(
"/client/sessions/{session_id}/queue/{queue_id}",
delete(hermes_client::cancel_queued_run),
)
.route("/client/events/{run_id}", get(hermes_client::stream_events))
.route(
"/client/runs/{run_id}/abort",
post(hermes_client::abort_run),
)
.route(
"/client/runs/{run_id}/resolve-permission",
post(hermes_client::resolve_permission),
)
.route("/client/models", get(hermes_client::list_models))
.route("/client/tools", get(hermes_client::list_tools)),
)
.nest(
"/api/hermes/tools",
Router::new()
.route("/mnote/manifest", get(hermes_tools::mnote_manifest))
.route("/mnote/call", post(hermes_tools::mnote_call))
.route("/mnote/audit", get(hermes_tools::mnote_audit)),
)
.nest(
"/api/mnote/tools",
Router::new()
.route("/manifest", get(hermes_tools::mnote_manifest))
.route("/call", post(hermes_tools::mnote_call))
.route("/audit", get(hermes_tools::mnote_audit)),
.route("/manifest", get(mnote_tools::mnote_manifest))
.route("/call", post(mnote_tools::mnote_call))
.route("/audit", get(mnote_tools::mnote_audit)),
);
if enable_debug_shell_routes {
@@ -1142,7 +845,6 @@ mod tests {
enable_debug_shell_routes,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1253,14 +955,11 @@ mod tests {
}
#[tokio::test]
async fn mnote_tools_have_current_alias_and_legacy_hermes_mount() {
async fn mnote_tools_mount_is_current_only() {
for (method, path) in [
("GET", "/api/mnote/tools/manifest"),
("POST", "/api/mnote/tools/call"),
("GET", "/api/mnote/tools/audit"),
("GET", "/api/hermes/tools/mnote/manifest"),
("POST", "/api/hermes/tools/mnote/call"),
("GET", "/api/hermes/tools/mnote/audit"),
] {
let response = app(false)
.oneshot(
@@ -1275,7 +974,7 @@ mod tests {
assert_ne!(
response.status(),
StatusCode::NOT_FOUND,
"{method} {path} 应挂到 MNote tool executorHermes 路径只作为 legacy alias 保留",
"{method} {path} 应挂到 MNote tool executor历史 alias 已移除;仅挂 MNote tool executor",
);
}
}
@@ -1543,12 +1242,12 @@ mod tests {
"rootUri": root_uri,
"documentId": "local-md:ai.md",
"updates": {
"ai.common.default_agent_id": "reasonix",
"ai.common.default_agent_id": "pi",
"ai.common.context_refs.default_selected": {
"current_page": true,
"folder": true
},
"ai.agent.hermes.profile_id": "mnoteai",
"ai.agent.pi.profile_id": "mnoteai",
"localOcr.autoEnabled": true
}
})
@@ -1597,7 +1296,7 @@ mod tests {
let alice_payload: Value = serde_json::from_slice(&alice_body).expect("alice json");
assert_eq!(
alice_payload["result"]["aiPreferences"]["ai.common.default_agent_id"],
"reasonix"
"pi"
);
assert_eq!(
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]
@@ -1605,7 +1304,7 @@ mod tests {
true
);
assert_eq!(
alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
alice_payload["result"]["aiPreferences"]["ai.agent.pi.profile_id"],
"mnoteai"
);
assert_eq!(
@@ -1662,14 +1361,7 @@ mod tests {
"/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
"/api/mnote-browser-runtime/agent-stream-event-router.js",
"/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
@@ -370,7 +370,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1999,7 +1999,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1,334 +0,0 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::extract::{Path, State};
use axum::{Extension, Json};
use reqwest::Method;
use serde_json::{json, Value};
use std::time::Duration;
const DEFAULT_BOARD_BASE_URL: &str = "http://127.0.0.1:3901/api";
const DEFAULT_BOARD_PROJECT_ID: &str = "51067826-50c7-4869-a8cd-5496f08ca8e6";
const DEFAULT_PAGE_AI_WORKFLOW_ID: &str = "builtin-mnote-page-ai-chat";
const DEFAULT_PAGE_AI_WORKER_PRESET_ID: &str = "mnote-page-ai-zcode";
const DEFAULT_PAGE_AI_MODEL_OVERRIDE: &str = "zcode-default";
fn page_ai_worker_options() -> Value {
json!([{
"id": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"name": "MNote 页面 AI · ZCode",
"surface": "mnote-page-ai",
"role": "developer",
"agentType": "zcode",
"capabilities": ["text", "repo-edit", "terminal", "mnote-capability-envelope", "local-markdown-edit"],
"modelOptions": page_ai_model_options(),
}])
}
fn page_ai_workflow_options() -> Value {
json!([{
"id": DEFAULT_PAGE_AI_WORKFLOW_ID,
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
"name": "MNote 页面 AI",
"surface": "mnote-page-ai",
"stages": ["answer"],
}])
}
fn page_ai_model_options() -> Value {
json!([
{ "id": "zcode-default", "label": "默认", "default": true },
{ "id": "zcode-fast", "label": "快速" },
{ "id": "zcode-strong", "label": "强力" },
])
}
fn validate_page_ai_route(
context: &RequestContext,
workflow_id: &str,
worker_preset_id: &str,
model_override: &str,
) -> Result<(), WebError> {
if workflow_id != DEFAULT_PAGE_AI_WORKFLOW_ID
|| worker_preset_id != DEFAULT_PAGE_AI_WORKER_PRESET_ID
{
return Err(WebError::bad_request_code(
"page_ai_board_route_not_allowed",
"Page AI 只能使用 mnote-page-ai 白名单 worker/workflow",
)
.with_context(context));
}
let allowed_models = ["zcode-default", "zcode-fast", "zcode-strong"];
if !allowed_models.contains(&model_override) {
return Err(WebError::bad_request_code(
"page_ai_board_model_not_allowed",
"Page AI 只能使用当前 MNote worker 允许的模型档位",
)
.with_context(context));
}
Ok(())
}
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
let has_actor = context.auth.actor_id.trim() != "anonymous";
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
return Ok(());
}
Err(WebError::new(
axum::http::StatusCode::UNAUTHORIZED,
"page_ai_board_unauthorized",
"页面 AI Agent Board bridge 需要登录后访问",
)
.with_context(context))
}
fn board_base_url() -> String {
std::env::var("MNOTE_AGENT_BOARD_API_BASE")
.or_else(|_| {
std::env::var("MNOTE_AGENT_BOARD_BASE_URL")
.map(|value| format!("{}/api", value.trim_end_matches('/')))
})
.unwrap_or_else(|_| DEFAULT_BOARD_BASE_URL.to_string())
.trim_end_matches('/')
.to_string()
}
fn default_project_id() -> String {
std::env::var("MNOTE_AGENT_BOARD_PROJECT_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BOARD_PROJECT_ID.to_string())
}
async fn board_request(
context: &RequestContext,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value, WebError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| {
WebError::internal(format!("Agent Board client 构造失败: {error}"))
.with_context(context)
})?;
let url = format!("{}{}", board_base_url(), path);
let mut request = client
.request(method, &url)
.header("accept", "application/json");
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_board_unreachable",
format!("无法连接 Agent Board: {error}"),
)
.with_context(context)
})?;
let status = response.status();
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"page_ai_board_error",
format!("Agent Board 返回 HTTP {status}: {payload}"),
)
.with_context(context)
.with_details(payload));
}
Ok(payload)
}
pub async fn status(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let payload = board_request(&context, Method::GET, "/health", None).await?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_status.v1",
"baseUrl": board_base_url(),
"projectId": default_project_id(),
"board": payload,
})))
}
pub async fn workers(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = default_project_id();
let payload = board_request(
&context,
Method::GET,
&format!("/workers/catalog?projectId={project_id}"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "agent_board.page_ai_route.v2",
"surface": "mnote-page-ai",
"projectId": project_id,
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"workerName": "MNote 页面 AI · ZCode",
"allowedWorkerPresetIds": [DEFAULT_PAGE_AI_WORKER_PRESET_ID],
"modelOverride": DEFAULT_PAGE_AI_MODEL_OVERRIDE,
"modelOptions": page_ai_model_options(),
"workers": page_ai_worker_options(),
"boardCatalog": payload,
})))
}
pub async fn workflows(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = default_project_id();
let payload = board_request(
&context,
Method::GET,
&format!("/workflow-presets?projectId={project_id}"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "agent_board.page_ai_route.v2",
"surface": "mnote-page-ai",
"projectId": project_id,
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
"workflowName": "MNote 页面 AI",
"allowedWorkflowIds": [DEFAULT_PAGE_AI_WORKFLOW_ID],
"requiresConfirmation": false,
"requires": {
"filesystem": true,
"write": false,
"browser": false,
"vision": false,
},
"stages": ["answer"],
"workflows": page_ai_workflow_options(),
"boardCatalog": payload,
})))
}
pub async fn create_run(
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(mut body): Json<Value>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = body
.get("projectId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(default_project_id);
let envelope = body.get("envelope").cloned().unwrap_or_else(|| json!({}));
let user_message = body
.get("message")
.and_then(Value::as_str)
.unwrap_or("请处理当前页面任务")
.trim();
let workflow_id = body
.get("workflowId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_WORKFLOW_ID)
.to_string();
let worker_preset_id = body
.get("workerPresetId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_WORKER_PRESET_ID)
.to_string();
let model_override = body
.get("modelOverride")
.and_then(Value::as_str)
.or_else(|| envelope.get("modelOverride").and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_MODEL_OVERRIDE)
.to_string();
validate_page_ai_route(&context, &workflow_id, &worker_preset_id, &model_override)?;
let board_message = format!(
"你正在处理 MNote Page AI 发来的任务。你的最终回复会直接显示在页面 AI 对话里。\n\n用户请求:\n{user_message}\n\nMNote Page AI envelope\n```json\n{}\n```\n\n要求:\n1. 只读问答要像普通页面 AI 一样直接回答用户,不要输出 Board 任务报告。\n2. 如果任务要求编辑页面,只修改 envelope.primaryTarget 指向的真实文件,不要调用 MNote 内部页面写入接口。\n3. 你的源头最终回答必须是自然语言 final answer;同时在结构化 receipt.finalAnswer/changedFiles/verification/remaining 中写入运行记录。\n4. 为兼容旧运行器,<task-summary> 可以包含 ## FINAL_ANSWER 段,但不要把 Completed/Comments/Remaining 当作用户主回答。",
serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_string())
);
body["projectId"] = Value::String(project_id.clone());
body["message"] = Value::String(board_message);
body["envelope"] = envelope;
body["surface"] = Value::String("mnote-page-ai".into());
body["workflowId"] = Value::String(workflow_id.clone());
body["workerPresetId"] = Value::String(worker_preset_id.clone());
body["modelOverride"] = Value::String(model_override.clone());
if body.get("autoRun").is_none() {
body["autoRun"] = Value::Bool(true);
}
let payload = board_request(&context, Method::POST, "/workflow-runs", Some(body)).await?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_run.v1",
"surface": "mnote-page-ai",
"projectId": project_id,
"workflowId": workflow_id,
"workerPresetId": worker_preset_id,
"modelOverride": model_override,
"board": payload,
})))
}
pub async fn get_run(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let details = board_request(
&context,
Method::GET,
&format!("/workflow-runs/{run_id}"),
None,
)
.await?;
let receipt = board_request(
&context,
Method::GET,
&format!("/workflow-runs/{run_id}/receipt"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_run_status.v1",
"runId": run_id,
"board": details,
"receipt": receipt,
})))
}
pub async fn cancel_run(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let payload = board_request(
&context,
Method::POST,
&format!("/workflow-runs/{run_id}/cancel"),
Some(json!({})),
)
.await?;
Ok(Json(
json!({ "ok": true, "runId": run_id, "board": payload }),
))
}
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::knowledge_rag as knowledge_rag_agent_output;
use crate::mnote_agent_tools::knowledge_rag as knowledge_rag_agent_output;
use crate::routes::{ai_settings, knowledge_rag, local_folder_source};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, StatusCode};
@@ -8956,7 +8956,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -1,7 +1,7 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::mnote_agent_tools::ToolCallInput;
use axum::extract::{Extension, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
@@ -132,7 +132,7 @@ pub async fn block_edit_workflow(
};
let apply_started = Instant::now();
let tool_response =
crate::routes::hermes_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
crate::routes::mnote_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
let apply_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
let apply_ms = apply_started.elapsed().as_millis();
info!(
@@ -518,21 +518,35 @@ fn quoted_segments(value: &str) -> Vec<String> {
segments
}
fn hermes_home() -> PathBuf {
std::env::var("HERMES_HOME")
fn agent_profile_home() -> PathBuf {
std::env::var("MNOTE_AGENT_HOME")
.ok()
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
std::env::var("HERMES_HOME")
.ok()
.map(|home| PathBuf::from(home).join(".hermes"))
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
})
.unwrap_or_else(|| PathBuf::from(".hermes"))
.or_else(|| {
std::env::var("HOME").ok().and_then(|home| {
let preferred = PathBuf::from(&home).join(".mnote-agent");
if preferred.exists() {
return Some(preferred);
}
let legacy = PathBuf::from(&home).join(".hermes");
if legacy.exists() {
return Some(legacy);
}
Some(preferred)
})
})
.unwrap_or_else(|| PathBuf::from(".mnote-agent"))
}
fn profile_config_path(profile: &str) -> PathBuf {
let home = hermes_home();
let home = agent_profile_home();
let profile = profile.trim();
if profile.is_empty() || profile == "default" {
return home.join("config.yaml");
@@ -598,7 +612,7 @@ mod tests {
use tower::util::ServiceExt;
fn env_lock() -> &'static Mutex<()> {
crate::test_support::hermes_env_lock()
crate::test_support::agent_env_lock()
}
fn app() -> axum::Router {
@@ -612,7 +626,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -763,12 +776,12 @@ mod tests {
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
let _guard = env_lock().lock().expect("env lock");
let base_url = spawn_mock_model_server().await;
let hermes_home = std::env::temp_dir().join(format!(
let agent_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-disabled-tool-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("mnoteai");
let _ = fs::remove_dir_all(&agent_home);
let profile_dir = agent_home.join("profiles").join("mnoteai");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
@@ -777,7 +790,7 @@ mod tests {
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
let response = app()
.oneshot(
@@ -821,20 +834,20 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "mnote_tool_disabled");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
#[tokio::test]
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
let _guard = env_lock().lock().expect("env lock");
let base_url = spawn_out_of_scope_mock_model_server().await;
let hermes_home = std::env::temp_dir().join(format!(
let agent_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-selection-scope-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("mnoteai");
let _ = fs::remove_dir_all(&agent_home);
let profile_dir = agent_home.join("profiles").join("mnoteai");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
@@ -843,7 +856,7 @@ mod tests {
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
let response = app()
.oneshot(
@@ -888,20 +901,20 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
#[tokio::test]
async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() {
let _guard = env_lock().lock().expect("env lock");
let base_url = spawn_read_and_edit_mock_model_server().await;
let hermes_home = std::env::temp_dir().join(format!(
let agent_home = std::env::temp_dir().join(format!(
"mnote-page-ai-workflow-read-summary-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&hermes_home);
let profile_dir = hermes_home.join("profiles").join("mnoteai");
let _ = fs::remove_dir_all(&agent_home);
let profile_dir = agent_home.join("profiles").join("mnoteai");
fs::create_dir_all(&profile_dir).expect("profile dir");
fs::write(
profile_dir.join("config.yaml"),
@@ -910,7 +923,7 @@ mod tests {
),
)
.expect("profile config");
std::env::set_var("HERMES_HOME", &hermes_home);
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
let response = app()
.oneshot(
@@ -962,7 +975,7 @@ mod tests {
.unwrap_or_default()
.contains("测试123"));
std::env::remove_var("HERMES_HOME");
let _ = fs::remove_dir_all(&hermes_home);
std::env::remove_var("MNOTE_AGENT_HOME");
let _ = fs::remove_dir_all(&agent_home);
}
}
@@ -1164,7 +1164,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+7 -10
View File
@@ -677,18 +677,18 @@ fn fallback_search_dataset(workspace_id: &str) -> Value {
"updatedAt": "2026-04-28T00:00:00Z"
},
{
"id": "doc_hermes",
"id": "doc_skill_graph",
"workspaceId": workspace_id,
"title": "Hermes",
"rawText": "Hermes 技能知识图谱开发 Wolai aline fixture",
"title": "SkillGraph",
"rawText": "SkillGraph 技能知识图谱开发 Wolai aline fixture",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
},
{
"id": "doc_hermes_skill",
"id": "doc_skill_path",
"workspaceId": workspace_id,
"title": "技能知识图谱开发",
"rawText": "Hermes 页面路径 个人 软件开发",
"rawText": "SkillGraph 页面路径 个人 软件开发",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
@@ -777,7 +777,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -908,7 +907,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -928,7 +926,7 @@ mod tests {
.body(Body::from(
json!({
"workspaceId": "ws_demo",
"query": "Hermes"
"query": "SkillGraph"
})
.to_string(),
))
@@ -944,7 +942,7 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["results"], json!([]));
assert_eq!(payload["meta"]["degraded"], true);
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
assert!(!payload.to_string().contains("SkillGraph 技能知识图谱开发"));
}
#[tokio::test]
@@ -1520,7 +1518,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+1 -5
View File
@@ -230,7 +230,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -333,7 +332,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -403,7 +401,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -460,7 +457,7 @@ mod tests {
#[tokio::test]
async fn session_returns_admin_for_local_access_policy_admin() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
let policy_root = std::env::temp_dir().join(format!(
@@ -484,7 +481,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
-1
View File
@@ -359,7 +359,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
-3
View File
@@ -2726,7 +2726,6 @@ mod tests {
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3072,7 +3071,6 @@ mod tests {
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3818,7 +3816,6 @@ mod tests {
enable_debug_shell_routes: true,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+267 -2
View File
@@ -10,9 +10,10 @@ use crate::routes::local_folder_source::{
self as local_folder_source, ensure_local_workspace_read_access_with_state,
ensure_local_workspace_write_access_with_state,
};
use crate::routes::vault_extension_token;
use crate::routes::vault_store::{
self, SecretPatch, VaultAccountSlot, VaultCreateInput, VaultItemStatus, VaultSecretSlot,
VaultUpdateInput,
self, SecretPatch, VaultAccountSlot, VaultCreateInput, VaultItemStatus, VaultLoginSession,
VaultSecretSlot, VaultSessionCookie, VaultUpdateInput,
};
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
@@ -1312,6 +1313,270 @@ pub async fn login_ai_item(
Ok(ok_response(&context, result))
}
/// PUT /api/vault/items/{id}/session — human / chrome-extension session file write (12-3)
/// body: { rootUri, accountId?, cookieHeader?, cookies[]?, origin?, expiresAt?, source? }
pub async fn put_item_session(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(id): Path<String>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = require_authenticated(&context)?;
let map = body.as_object().ok_or_else(|| {
WebError::bad_request_code("vault_body_invalid", "请求体必须是 JSON 对象")
})?;
let root_uri = require_root_uri(
map.get("rootUri")
.and_then(Value::as_str)
.or_else(|| map.get("root_uri").and_then(Value::as_str)),
)?;
let root = resolve_write_root(&state, &context, root_uri).await?;
let account_id = map
.get("accountId")
.or_else(|| map.get("account_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let cookies = parse_session_cookies(map.get("cookies"));
let cookie_header = map
.get("cookieHeader")
.or_else(|| map.get("cookie_header"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
if cookie_header.is_none() && cookies.is_empty() {
return Err(WebError::bad_request_code(
"vault_session_cookie_required",
"cookieHeader 与 cookies[] 至少一个非空",
));
}
let source = map
.get("source")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("chrome_extension")
.to_string();
let session = VaultLoginSession {
cookie_header,
expires_at: map
.get("expiresAt")
.or_else(|| map.get("expires_at"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
last_login_at: map
.get("lastLoginAt")
.or_else(|| map.get("last_login_at"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
source: Some(source),
origin: map
.get("origin")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
account_id: account_id.clone(),
cookies,
revision: None,
};
let record = vault_store::put_login_session_for_account(
&root,
&id,
account_id.as_deref(),
session,
)?;
let _ = vault_store::append_vault_audit(
&root,
"session_put",
&actor,
&id,
None,
Some(context.trace.request_id.as_str()),
true,
);
let acc = record
.login_session
.as_ref()
.and_then(|s| s.account_id.clone())
.or(account_id)
.unwrap_or_else(|| "primary".into());
let expires = record
.login_session
.as_ref()
.and_then(|s| s.expires_at.clone());
let rev = record
.login_session
.as_ref()
.and_then(|s| s.revision)
.unwrap_or(1);
Ok(ok_response(
&context,
json!({
"credentialId": id,
"accountId": acc,
"hasLoginSession": true,
"sessionExpiresAt": expires,
"revision": rev,
"item": vault_store::project_item_l0_with_cipher(&record, Some(&root)),
}),
))
}
fn parse_session_cookies(value: Option<&Value>) -> Vec<VaultSessionCookie> {
let Some(arr) = value.and_then(Value::as_array) else {
return Vec::new();
};
arr.iter()
.filter_map(|item| {
let obj = item.as_object()?;
let name = obj
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())?
.to_string();
let value = obj
.get("value")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
Some(VaultSessionCookie {
name,
value,
domain: obj
.get("domain")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
path: obj
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
secure: obj.get("secure").and_then(Value::as_bool),
http_only: obj
.get("httpOnly")
.or_else(|| obj.get("http_only"))
.and_then(Value::as_bool),
same_site: obj
.get("sameSite")
.or_else(|| obj.get("same_site"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
expiration_date: obj
.get("expirationDate")
.or_else(|| obj.get("expiration_date"))
.and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))),
})
})
.collect()
}
/// POST /api/vault/extension/token — issue E2 human token (mnext1.*) after session login.
pub async fn issue_extension_token(
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let actor = require_authenticated(&context)?;
let map = body.as_object();
let client_id = map
.and_then(|m| m.get("clientId").or_else(|| m.get("client_id")))
.and_then(Value::as_str);
let extension_id = map
.and_then(|m| m.get("extensionId").or_else(|| m.get("extension_id")))
.and_then(Value::as_str);
let ttl_hours = map
.and_then(|m| m.get("ttlHours").or_else(|| m.get("ttl_hours")))
.and_then(Value::as_u64);
let email = map
.and_then(|m| m.get("email"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let issued = vault_extension_token::issue_extension_token(
&actor,
email,
client_id,
extension_id,
ttl_hours,
)?;
Ok(ok_response(
&context,
json!({
"token": issued.token,
"expiresAt": vault_extension_token::exp_to_rfc3339(issued.claims.exp),
"scope": issued.claims.scope,
"userId": issued.claims.actor,
"email": issued.claims.email,
"jti": issued.claims.jti,
"aud": issued.claims.aud,
}),
))
}
/// POST /api/vault/extension/token/revoke — revoke by jti or current Bearer.
pub async fn revoke_extension_token(
Extension(context): Extension<RequestContext>,
Json(body): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
require_vault_enabled()?;
let _actor = require_authenticated(&context)?;
let jti_from_body = body
.get("jti")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let jti = if let Some(j) = jti_from_body {
j
} else if let Some(token) =
vault_extension_token::bearer_mnext1(context.auth.authorization.as_deref())
{
let claims = vault_extension_token::verify_extension_token(token)?;
claims.jti
} else {
return Err(WebError::bad_request_code(
"vault_ext_jti_required",
"吊销需要 jti 或当前 Bearer mnext1 token",
));
};
vault_extension_token::revoke_jti(&jti)?;
Ok(ok_response(
&context,
json!({
"revoked": true,
"jti": jti,
}),
))
}
/// POST /api/vault/ai/items/{id}/session — human/browser cookie write-back
/// body: { cookieHeader, expiresAt?, source? }
pub async fn put_ai_session(
@@ -0,0 +1,466 @@
//! Chrome extension human token (12-3 E2).
//!
//! Format: `mnext1.<base64url(payload_json)>.<base64url(hmac_sha256)>`
//! Separate HMAC key / aud from agent `mnv1.*` tokens (12-2).
use crate::error::WebError;
use axum::http::StatusCode;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
pub const TOKEN_PREFIX: &str = "mnext1";
pub const TOKEN_VERSION: u32 = 1;
pub const ISSUER: &str = "mnote-web";
pub const AUDIENCE: &str = "chrome-extension-vault";
pub const SCOPE_VIEW: &str = "vault.view";
pub const SCOPE_EDIT: &str = "vault.edit";
pub const DEFAULT_TTL_HOURS: u64 = 168;
pub const MAX_TTL_HOURS: u64 = 720; // 30d
const DEFAULT_HMAC_KEY_REL: &str = ".config/mnote/vault-extension-hmac.key";
const DEFAULT_REVOKE_REL: &str = ".config/mnote/vault-extension-revoked.jti";
static REVOKE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExtensionTokenClaims {
pub v: u32,
pub iss: String,
pub aud: String,
pub sub: String,
pub actor: String,
pub scope: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extension_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
pub iat: u64,
/// 0 = no expiry (not used for extension tokens)
pub exp: u64,
pub jti: String,
}
#[derive(Debug, Clone)]
pub struct IssuedExtensionToken {
pub token: String,
pub claims: ExtensionTokenClaims,
}
fn dirs_path_home() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn default_hmac_key_path() -> PathBuf {
if let Ok(p) = std::env::var("MNOTE_VAULT_EXTENSION_HMAC_KEY") {
let p = p.trim();
if !p.is_empty() {
return PathBuf::from(p);
}
}
dirs_path_home().join(DEFAULT_HMAC_KEY_REL)
}
fn default_revoke_path() -> PathBuf {
if let Ok(p) = std::env::var("MNOTE_VAULT_EXTENSION_REVOKE_FILE") {
let p = p.trim();
if !p.is_empty() {
return PathBuf::from(p);
}
}
dirs_path_home().join(DEFAULT_REVOKE_REL)
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn getrandom_fill(buf: &mut [u8]) -> Result<(), WebError> {
use std::io::Read;
let mut f = fs::File::open("/dev/urandom").map_err(|e| {
WebError::internal(format!("open /dev/urandom: {e}"))
})?;
f.read_exact(buf)
.map_err(|e| WebError::internal(format!("read urandom: {e}")))?;
Ok(())
}
/// Load or create a random 32-byte HMAC secret (hex file, 0600 when possible).
pub fn load_or_create_hmac_key(path: &Path) -> Result<Vec<u8>, WebError> {
if path.exists() {
let raw = fs::read_to_string(path).map_err(|e| {
WebError::bad_request_code(
"vault_ext_hmac_key_read_failed",
format!("无法读取 extension HMAC key {}: {e}", path.display()),
)
})?;
let hex = raw.trim();
if hex.len() < 32 {
return Err(WebError::bad_request_code(
"vault_ext_hmac_key_invalid",
"extension HMAC key 过短",
));
}
return hex::decode(hex).map_err(|e| {
WebError::bad_request_code(
"vault_ext_hmac_key_invalid",
format!("extension HMAC key 非 hex: {e}"),
)
});
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
WebError::bad_request_code(
"vault_ext_hmac_key_write_failed",
format!("无法创建目录: {e}"),
)
})?;
}
let mut bytes = [0u8; 32];
getrandom_fill(&mut bytes)?;
let hex = hex::encode(bytes);
fs::write(path, format!("{hex}\n")).map_err(|e| {
WebError::bad_request_code(
"vault_ext_hmac_key_write_failed",
format!("无法写入 extension HMAC key: {e}"),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(bytes.to_vec())
}
pub fn issue_extension_token(
actor_id: &str,
email: Option<&str>,
client_id: Option<&str>,
extension_id: Option<&str>,
ttl_hours: Option<u64>,
) -> Result<IssuedExtensionToken, WebError> {
let actor = actor_id.trim();
if actor.is_empty() || actor == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_auth_required",
"签发 extension token 需要已登录会话",
));
}
let hours = ttl_hours
.unwrap_or(DEFAULT_TTL_HOURS)
.clamp(1, MAX_TTL_HOURS);
let iat = now_unix();
let exp = iat.saturating_add(hours.saturating_mul(3600));
let claims = ExtensionTokenClaims {
v: TOKEN_VERSION,
iss: ISSUER.into(),
aud: AUDIENCE.into(),
sub: format!("user:{actor}"),
actor: actor.to_string(),
scope: vec![SCOPE_VIEW.into(), SCOPE_EDIT.into()],
client_id: client_id
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
extension_id: extension_id
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
email: email
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned),
iat,
exp,
jti: Uuid::new_v4().to_string(),
};
let key = load_or_create_hmac_key(&default_hmac_key_path())?;
let token = encode_token(&key, &claims)?;
Ok(IssuedExtensionToken { token, claims })
}
pub fn encode_token(hmac_key: &[u8], claims: &ExtensionTokenClaims) -> Result<String, WebError> {
let payload = serde_json::to_vec(claims).map_err(|e| {
WebError::internal(format!("extension token serialize: {e}"))
})?;
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload);
let mut mac = HmacSha256::new_from_slice(hmac_key)
.map_err(|e| WebError::internal(format!("hmac init: {e}")))?;
mac.update(payload_b64.as_bytes());
let sig = mac.finalize().into_bytes();
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
Ok(format!("{TOKEN_PREFIX}.{payload_b64}.{sig_b64}"))
}
/// Verify Bearer raw token string (with or without "Bearer " prefix stripped by caller).
pub fn verify_extension_token(token: &str) -> Result<ExtensionTokenClaims, WebError> {
let token = token.trim();
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 || parts[0] != TOKEN_PREFIX {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token 格式无效(期望 mnext1.<payload>.<sig>",
));
}
let key = load_or_create_hmac_key(&default_hmac_key_path())?;
let payload_b64 = parts[1];
let sig_b64 = parts[2];
let mut mac = HmacSha256::new_from_slice(&key)
.map_err(|e| WebError::internal(format!("hmac init: {e}")))?;
mac.update(payload_b64.as_bytes());
let expected = mac.finalize().into_bytes();
let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token 签名解码失败",
)
})?;
if sig.len() != expected.len()
|| sig
.iter()
.zip(expected.iter())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
!= 0
{
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token 签名校验失败",
));
}
let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token payload 解码失败",
)
})?;
let claims: ExtensionTokenClaims = serde_json::from_slice(&payload).map_err(|_| {
WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token payload JSON 无效",
)
})?;
if claims.v != TOKEN_VERSION {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
format!("不支持的 extension token 版本 {}", claims.v),
));
}
if claims.aud != AUDIENCE {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token aud 不匹配",
));
}
if claims.iss != ISSUER {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token iss 不匹配",
));
}
if claims.exp != 0 && now_unix() > claims.exp {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_expired",
"extension token 已过期",
));
}
if is_jti_revoked(&claims.jti)? {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_revoked",
"extension token 已吊销",
));
}
if claims.actor.trim().is_empty() || claims.actor.trim() == "anonymous" {
return Err(WebError::new(
StatusCode::UNAUTHORIZED,
"vault_ext_token_invalid",
"extension token actor 无效",
));
}
Ok(claims)
}
pub fn require_scope(claims: &ExtensionTokenClaims, need: &str) -> Result<(), WebError> {
let set: BTreeSet<&str> = claims.scope.iter().map(|s| s.as_str()).collect();
if set.contains(need) || set.contains("*") {
return Ok(());
}
Err(WebError::new(
StatusCode::FORBIDDEN,
"vault_ext_scope_denied",
format!("extension token 缺少 scope: {need}"),
))
}
/// Extract Bearer token if it looks like mnext1.*
pub fn bearer_mnext1(authorization: Option<&str>) -> Option<&str> {
let auth = authorization?.trim();
let token = auth
.strip_prefix("Bearer ")
.or_else(|| auth.strip_prefix("bearer "))
.map(str::trim)
.filter(|s| !s.is_empty())?;
if token.starts_with(TOKEN_PREFIX) {
Some(token)
} else {
None
}
}
fn load_revoked_jtis(path: &Path) -> Result<BTreeSet<String>, WebError> {
if !path.exists() {
return Ok(BTreeSet::new());
}
let raw = fs::read_to_string(path).map_err(|e| {
WebError::internal(format!("读取 extension revoke 文件失败: {e}"))
})?;
let mut set = BTreeSet::new();
for line in raw.lines() {
let j = line.trim();
if !j.is_empty() && !j.starts_with('#') {
set.insert(j.to_string());
}
}
Ok(set)
}
fn is_jti_revoked(jti: &str) -> Result<bool, WebError> {
let path = default_revoke_path();
let _guard = REVOKE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let set = load_revoked_jtis(&path)?;
Ok(set.contains(jti.trim()))
}
pub fn revoke_jti(jti: &str) -> Result<(), WebError> {
let jti = jti.trim();
if jti.is_empty() {
return Err(WebError::bad_request_code(
"vault_ext_jti_required",
"吊销需要 jti",
));
}
let path = default_revoke_path();
let _guard = REVOKE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut set = load_revoked_jtis(&path)?;
if !set.insert(jti.to_string()) {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
WebError::internal(format!("创建 revoke 目录失败: {e}"))
})?;
}
let mut lines: Vec<String> = set.into_iter().collect();
lines.sort();
let body = format!("{}\n", lines.join("\n"));
fs::write(&path, body).map_err(|e| {
WebError::internal(format!("写入 revoke 文件失败: {e}"))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
pub fn exp_to_rfc3339(exp: u64) -> String {
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
OffsetDateTime::from_unix_timestamp(exp as i64)
.ok()
.and_then(|t| t.format(&Rfc3339).ok())
.unwrap_or_else(|| exp.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn with_temp_key_env<F: FnOnce()>(f: F) {
let _g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!(
"mnote-ext-token-test-{}",
Uuid::new_v4()
));
let _ = fs::create_dir_all(&dir);
let key = dir.join("hmac.key");
let rev = dir.join("revoked.jti");
std::env::set_var("MNOTE_VAULT_EXTENSION_HMAC_KEY", &key);
std::env::set_var("MNOTE_VAULT_EXTENSION_REVOKE_FILE", &rev);
f();
std::env::remove_var("MNOTE_VAULT_EXTENSION_HMAC_KEY");
std::env::remove_var("MNOTE_VAULT_EXTENSION_REVOKE_FILE");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn issue_verify_roundtrip() {
with_temp_key_env(|| {
let issued = issue_extension_token(
"user_demo",
Some("demo@example.com"),
Some("chrome-extension"),
Some("ext_id_1"),
Some(24),
)
.expect("issue");
assert!(issued.token.starts_with("mnext1."));
let claims = verify_extension_token(&issued.token).expect("verify");
assert_eq!(claims.actor, "user_demo");
assert!(claims.scope.contains(&SCOPE_VIEW.to_string()));
assert!(claims.scope.contains(&SCOPE_EDIT.to_string()));
require_scope(&claims, SCOPE_EDIT).expect("scope");
});
}
#[test]
fn revoke_blocks_verify() {
with_temp_key_env(|| {
let issued =
issue_extension_token("user_demo", None, None, None, Some(1)).expect("issue");
revoke_jti(&issued.claims.jti).expect("revoke");
let err = verify_extension_token(&issued.token).expect_err("revoked");
assert_eq!(err.code(), "vault_ext_token_revoked");
});
}
#[test]
fn agent_token_prefix_rejected() {
with_temp_key_env(|| {
let err = verify_extension_token("mnv1.abc.def").expect_err("reject");
assert_eq!(err.code(), "vault_ext_token_invalid");
});
}
}
File diff suppressed because it is too large Load Diff
+11 -177
View File
@@ -328,7 +328,6 @@ pub async fn document_page_shell(
vault_nav_href={vault_nav_href}
/>
});
let hermes_settings_config_script = render_hermes_settings_config_script();
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
@@ -349,7 +348,6 @@ pub async fn document_page_shell(
{}
{}
{}
{}
</body>
</html>"#,
escape_html(title),
@@ -364,7 +362,6 @@ pub async fn document_page_shell(
escape_script_json(&snapshot_json),
escape_script_json(&bootstrap_json),
escape_script_json(&panes_bootstrap_json),
hermes_settings_config_script,
secondary_snapshot_json
.as_ref()
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
@@ -510,59 +507,6 @@ pub(crate) fn build_editor_bootstrap_json(
)
}
fn render_hermes_settings_config_script() -> String {
let Some(base_url) = [
"MNOTE_WEB_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_UPSTREAM_URL",
"MNOTE_HERMES_API_BASE_URL",
]
.into_iter()
.find_map(env_or_dotenv)
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()) else {
return String::new();
};
let settings_url = format!("{base_url}/hermes/settings");
let encoded = serde_json::to_string(&settings_url).unwrap_or_else(|_| "\"\"".to_string());
format!(
r#"<script>window.__mnoteHermesSettingsUrl = {};</script>"#,
escape_script_json(&encoded)
)
}
fn env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
if cfg!(test) {
return None;
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((candidate_key, value)) = line.split_once('=') else {
continue;
};
if candidate_key.trim() != key {
continue;
}
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
pub(crate) fn build_editor_bootstrap_json_with_ids(
aggregate: &PageAggregate,
context: &RequestContext,
@@ -2511,15 +2455,6 @@ pub async fn sidebar_tree_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn agent_stream_event_router_asset() -> Response {
const JS: &str = include_str!("../../browser/agent-stream-event-router.js");
Response::builder()
.header("content-type", "application/javascript; charset=utf-8")
.header("cache-control", "public, max-age=3600")
.body(Body::from(JS))
.expect("agent-stream-event-router.js")
}
pub async fn sidebar_page_ai_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js");
Response::builder()
@@ -2534,104 +2469,6 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_markdown_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-markdown-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_render_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-render-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_permission_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-permission-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_profile_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-profile-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_session_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-session-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_skill_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-skill-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_target_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-target-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn sidebar_page_ai_pi_lab_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-runtime.js");
Response::builder()
@@ -3749,7 +3586,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3807,7 +3643,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -3929,7 +3764,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:9".into()),
convex_admin_key: Some("test-admin-key".into()),
@@ -4072,7 +3906,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -4447,7 +4280,7 @@ mod tests {
#[tokio::test]
async fn mnote_browser_runtime_assets_are_not_cached_during_dev_hot() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4476,7 +4309,7 @@ mod tests {
#[tokio::test]
async fn mnote_browser_runtime_module_imports_carry_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4508,7 +4341,7 @@ mod tests {
#[tokio::test]
async fn editor_runtime_preload_links_use_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4536,7 +4369,7 @@ mod tests {
#[tokio::test]
async fn leptos_tiptap_entry_imports_carry_dev_hot_buster() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4570,7 +4403,7 @@ mod tests {
#[tokio::test]
async fn dev_hot_runtime_serves_page_block_pane_navigation_bridge() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -4841,7 +4674,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -4910,7 +4742,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -5069,7 +4900,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
@@ -5339,7 +5169,12 @@ mod tests {
assert!(html.contains("Local Shell"));
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
assert!(html.contains("Child Page"));
// lazy PageTree:只 reveal active 文档路径,不把 sibling「Child Page」扫进首屏。
assert!(
html.contains(r#"data-node-id="local-md:Local~20Shell~2FLocal~20Shell.md""#)
|| html.contains("Local Shell"),
"active 本地页应出现在 shell / PageTree 首屏"
);
assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind="));
assert!(html.contains("data-page-openable=\"false\""));
@@ -5620,7 +5455,6 @@ mod tests {
enable_debug_shell_routes: false,
enable_editor_actor: true,
enable_page_ai_pi_lab: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
+83 -205
View File
@@ -241,25 +241,12 @@ mod tests {
include_str!("../../../browser/sidebar-workspace-runtime.js");
const SIDEBAR_PAGE_TREE_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-tree-runtime.js");
// Browser Page AI runtime is served as a module; keep this include explicit so
// resume/journal contract checks inspect the actual shipped JS.
// Page AI 产品面:facadePi 入口桥接)+ Pi Lab runtime。Hermes/OpenCode 子模块已物理删除。
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-runtime.js");
const SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-pi-lab-runtime.js");
const MNOTE_UI_RUNTIME_JS: &str = include_str!("../../../browser/mnote-ui-runtime.js");
const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-markdown-runtime.js");
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-render-runtime.js");
const SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-permission-runtime.js");
const SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-profile-runtime.js");
const SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-session-runtime.js");
const SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-skill-runtime.js");
const SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-ai-target-runtime.js");
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
include_str!("../../../browser/sidebar-page-settings-runtime.js");
const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str =
@@ -341,7 +328,12 @@ mod tests {
assert!(SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("dragstart"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("drop"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-drop-feedback"));
// drop feedback 已外置到 filetree-dnd / page-tree runtimetree 仍委托 drop 入口
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("data-drop-feedback")
|| FILETREE_DND_RUNTIME_JS.contains("data-drop-feedback")
|| SIDEBAR_PAGE_TREE_RUNTIME_JS.contains("data-drop-feedback")
);
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("action: 'move'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebar-file-tree-root"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.open"));
@@ -623,7 +615,7 @@ mod tests {
#[test]
fn page_layout_adds_dev_hot_cache_buster_to_browser_runtime_scripts() {
let _guard = crate::test_support::hermes_env_lock()
let _guard = crate::test_support::agent_env_lock()
.lock()
.expect("env lock");
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
@@ -745,6 +737,8 @@ mod tests {
< html.find(r#"data-testid="wolai-floating-ai""#)
);
assert!(html.contains(r#"data-mnote-action="open-page-ai-pi-lab""#));
assert!(html.contains(r#"aria-label="Pi Lab""#));
assert!(html.contains(r#"title="打开 Pi Lab""#));
assert!(!html.contains(r#"data-mnote-action="open-page-ai""#));
assert!(!html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
assert!(html.contains(r#"data-icon="travel_explore""#));
@@ -755,6 +749,33 @@ mod tests {
assert!(!html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
}
#[test]
fn page_ai_product_entry_is_pi_lab_only() {
// 产品入口:浮钮 + tree click + openPageAiDrawer 均只打开 Pi Lab。
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("产品唯一入口:Pi Lab"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function openPageAiDrawer"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function updatePageAiTriggerState"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("createSidebarPageAiPiLabRuntime"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("产品面唯一 Page AIPi Lab"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains(r#"data-mnote-action="open-page-ai-pi-lab""#));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
// Hermes/OpenCode drawer 与子模块已物理删除
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiOpencodeHostEnabled"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("切换到 Hermes"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("切换到 OpenCode"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("切换到 Hermes"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("切换到 OpenCode"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiOpenHermesSettings"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiSetHideHermesBuiltinSkills"));
}
#[test]
fn sidebar_settings_runtime_routes_index_and_ocr_to_knowledge_rag_settings() {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
@@ -879,196 +900,43 @@ mod tests {
}
#[test]
fn page_ai_fast_path_is_not_local_first_main_path() {
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS
.contains("if (currentSourceKind() === 'local_folder') return false;"),
"local-first 页面 AI 不应继续加厚 page-ai fast-path;本地编辑应走受控文件工具"
);
fn page_ai_facade_is_pi_lab_bridge_only() {
// facade 只桥接 Pi Lab;不承载 Hermes session / ACP / OpenCode host。
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("export function createSidebarPageAiRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("window.createSidebarPageAiPiLabRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote:pi-lab-hide"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("window.__mnoteSidebarPageAiRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiOpencode"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("reasonix"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/sessions"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiMarkdownRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiPermissionRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiProfileRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSkillRuntime"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiTargetRuntime"));
}
#[test]
fn page_ai_local_source_passes_file_reference_fields_to_agent_run() {
fn page_ai_pi_lab_runtime_is_product_host() {
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("function createSidebarPageAiPiLabRuntime"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-show"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("mnote:pi-lab-hide"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("/api/page-ai/pi/"));
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentRootUri()"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("sourceKind: currentSourceKind()"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("rootUri: currentRootUri()"));
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
.contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind"));
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
.contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageContext: scopedContext.pageContext"));
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS
.contains("if (currentSourceKind() === 'local_folder') return false;"),
"local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope"
);
assert!(
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"),
"Page AI 目标包不应保留已退役 OCR sidecar context enrichment"
);
assert!(
!SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("ocrRootRelativePath"),
"Page AI target runtime 不应再注入旧 OCR sidecar 路径"
);
}
#[test]
fn page_ai_agent_target_picker_contract_is_visible_and_serialized() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiRenderRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS
.contains("export function createSidebarPageAiRenderRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-button"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-popover"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-option"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-target-chip"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("primaryTargetId"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("targets: ["));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("policy: {"));
}
#[test]
fn page_ai_uses_backend_acp_session_runtime_store() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSessionRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiSkillRuntime"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiTargetRuntime"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiControls"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function ensurePageAiDrawer"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function renderPageAiConversation"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS
.contains("export function createSidebarPageAiSkillRuntime"));
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS
.contains("export function createSidebarPageAiTargetRuntime"));
assert!(
SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("function currentPageAiOpenEditorsSnapshot")
);
assert!(SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("mnote.agent_target_package.v1"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillSourceOptions"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("function pageAiSkillPreferenceTable"));
assert!(SIDEBAR_PAGE_AI_SKILL_RUNTIME_JS.contains("ai.agent.reasonix.memory_enabled"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/page-ai/sessions?"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("params.set('source', 'acp')"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains(
"var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter"
));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
.contains("pageAiDedupeSessions(backendSessions.concat(draftSessions))"));
assert!(
SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")
);
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSearchBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
.contains("function pageAiDeleteSelectedBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiCheckActiveRun"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/page-ai/sessions/"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/active-run?"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-rename"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-select"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete-selected"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-search"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderThoughtGroup"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-thought-card"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-collapse-card"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("usage.updated"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("thought.delta"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("permission.requested"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
.contains("data-page-ai-permission-action=\"allow\""));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS
.contains("data-page-ai-permission-action=\"deny\""));
assert!(
SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiHidePermissionDialog")
);
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("if (!message.resolved)"));
assert!(
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("(item.resolved ? ' disabled' : '')"),
"已决 ACP permission 事件不能继续展示假审批按钮"
);
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("session.info.updated"),
"ACP SessionInfoUpdate 事件应通过 session.info.updated SSE 转发到前端"
);
assert!(
SIDEBAR_PAGE_AI_RUNTIME_JS.contains("plan.updated"),
"ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端"
);
assert!(
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-plan"),
"plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板"
);
assert!(
SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("执行计划 · "),
"plan 面板标题应显示执行计划和步数"
);
}
#[test]
fn page_ai_session_ui_labels_local_shared_and_cloud_storage() {
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSessionStorageLabel"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_private"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_shared"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sqlite_control_plane"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("convex_acp_runtime_store"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("本地私有"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("共享会话"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("账号会话"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("云端会话"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sessionStorage:"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("permissionLevel:"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("shareId:"));
}
#[test]
fn page_ai_acp_runtime_legacy_selector_contract_is_explicit() {
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 4"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function ensurePageAiStateFacade"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiPermissionRuntime"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("function pageAiResolvePermission"));
assert!(SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS.contains("resolve-permission"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("createSidebarPageAiProfileRuntime"));
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes"));
assert!(SIDEBAR_PAGE_AI_PROFILE_RUNTIME_JS.contains("return ['reasonix', 'hermes']"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiPointerDown"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai.drawer_width"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-resize-handle"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiOpenCitationUrl"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("a[data-page-ai-citation-link=\"true\"]"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("data-page-ai-citation-link=\"true\""));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("renderPageAiMarkdownTable"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("wolai-page-ai-markdown-table-wrap"));
assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("target=\"_blank\""));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("document.addEventListener('visibilitychange'"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiCheckActiveRun(sessionId || undefined)"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiCheckAndResumeActiveRun"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiResumeActiveRunJournal"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiTrackStreamingRunEvent"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("void pageAiCheckAndResumeActiveRun().catch"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("mnote.page_ai_active_run_snapshot.v1"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-mnote-page-ai-active-run-last-seq"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/runs/"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("afterSeq="));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiCheckAndResumeActiveRun()"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/page-ai/agents/descriptors"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadAgentDescriptors"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-descriptor-field"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderDescriptorField"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-agent-descriptor-card"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.handlePageAi"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("closestAction(e.target, '[data-page-ai-action"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
.contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'"));
assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("默认 (Hermes HTTP)"));
// 已退役:OCR sidecar / Hermes agent 切换 / ACP session store 前端
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"));
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("ocrRootRelativePath"));
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("切换到 Hermes"));
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("切换到 OpenCode"));
}
#[test]
@@ -1158,8 +1026,10 @@ mod tests {
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("tree:local-folder-watch-batch"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("viaEventBus: true"));
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("emitSyntheticWatchBatch"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("synthetic_page_ai_receipt"));
// Page AI 写回本地文件夹:由 Pi Lab runtime 调 event bus,不再经 legacy drawer。
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("emitChangedFiles"));
assert!(SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("pi_lab_tool_call"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteLocalFolderEventBus"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("startLocalFolderWatcher"));
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
@@ -2032,9 +1902,17 @@ mod tests {
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("recordFileTreeActionStatus('blocked'")
);
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("/api/tree/filetree/drop-preflight"));
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'"));
assert!(
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds")
|| SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
.contains("moveSidebarFileTreeRows(clipboard.rowIds")
);
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'")
|| FILETREE_DND_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'")
|| SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("ensureFileTreeWritableTarget")
);
}
#[test]
+2 -1
View File
@@ -220,7 +220,8 @@ mod tests {
assert!(MNOTE_CSS.len() > 2000);
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装;
// 当前包含主壳、Page AI、搜索、toast、debug 与 vault 样式,继续用上限防止意外重复打包。
assert!(MNOTE_CSS.len() < 220000);
// vault workbench 样式增长后合计约 234KB;上限放宽到 280KB。
assert!(MNOTE_CSS.len() < 280000);
}
#[test]
@@ -2002,37 +2002,6 @@ button.wolai-page-ai-history-main span {
border-color: rgba(27, 28, 28, 0.32);
}
.wolai-page-ai-reasonix-controls {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 0 8px 6px;
}
.wolai-page-ai-reasonix-controls[hidden] {
display: none !important;
}
.wolai-page-ai-reasonix-control {
display: grid;
gap: 2px;
min-width: 0;
color: #8B8782;
font-size: 11px;
}
.wolai-page-ai-reasonix-control select {
width: 100%;
min-width: 0;
height: 28px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 7px;
background: #FFF;
color: #1B1C1C;
font-size: 12px;
}
.wolai-page-ai-composer-bar {
display: flex;
min-height: 36px;
@@ -2214,298 +2183,3 @@ button.wolai-page-ai-history-main span {
}
}
.wolai-page-ai-drawer[data-page-ai-opencode-host="true"] .wolai-page-ai-panel {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
}
.wolai-page-ai-opencode-header {
flex: 0 0 auto;
}
.wolai-page-ai-opencode-chrome {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 6px;
padding: 8px 12px 10px;
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
background: rgba(247, 247, 245, 0.92);
}
.wolai-page-ai-opencode-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: 6px;
align-items: baseline;
font-size: 12px;
color: rgba(27, 28, 28, 0.58);
}
.wolai-page-ai-opencode-row strong,
.wolai-page-ai-opencode-row code {
min-width: 0;
overflow: hidden;
color: rgba(27, 28, 28, 0.86);
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-opencode-badges,
.wolai-page-ai-opencode-files {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.wolai-page-ai-opencode-badges span,
.wolai-page-ai-opencode-empty,
.wolai-page-ai-opencode-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 24px;
padding: 3px 8px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 999px;
background: #fff;
color: rgba(27, 28, 28, 0.68);
font: inherit;
font-size: 12px;
}
.wolai-page-ai-opencode-chip {
cursor: pointer;
}
.wolai-page-ai-opencode-chip:hover {
border-color: rgba(35, 131, 226, 0.32);
color: var(--wolai-accent, #2383e2);
}
.wolai-page-ai-opencode-chip span {
margin-left: 6px;
color: rgba(27, 28, 28, 0.45);
}
.wolai-page-ai-opencode-frame-wrap {
position: relative;
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
background: #fff;
}
.wolai-page-ai-opencode-iframe {
flex: 1 1 auto;
width: 100%;
min-height: 0;
border: 0;
background: #fff;
}
.wolai-page-ai-opencode-iframe[hidden] {
display: none;
}
.wolai-page-ai-opencode-iframe-fallback {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
color: rgba(27, 28, 28, 0.62);
text-align: center;
background: #fff;
}
.wolai-page-ai-opencode-iframe-fallback[hidden] {
display: none;
}
.wolai-page-ai-opencode-chat {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
background: #fff;
}
.wolai-page-ai-opencode-messages {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 12px;
}
.wolai-page-ai-opencode-message {
margin: 0 0 10px;
padding: 10px 12px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 12px;
background: rgba(247, 247, 245, 0.9);
}
.wolai-page-ai-opencode-message[data-role="assistant"] {
background: #fff;
}
.wolai-page-ai-opencode-message-role {
margin-bottom: 4px;
color: rgba(27, 28, 28, 0.52);
font-size: 11px;
font-weight: 600;
}
.wolai-page-ai-opencode-message-body {
color: rgba(27, 28, 28, 0.88);
font-size: 13px;
line-height: 1.55;
}
.wolai-page-ai-opencode-composer {
display: flex;
gap: 8px;
padding: 10px 12px 12px;
border-top: 1px solid rgba(27, 28, 28, 0.08);
}
.wolai-page-ai-opencode-composer textarea {
flex: 1 1 auto;
min-height: 42px;
resize: vertical;
border: 1px solid rgba(27, 28, 28, 0.14);
border-radius: 10px;
padding: 8px 10px;
font: inherit;
}
.wolai-page-ai-opencode-send,
.wolai-page-ai-opencode-permission button {
border: 0;
border-radius: 10px;
padding: 0 12px;
background: #1f6feb;
color: #fff;
font-weight: 600;
}
.wolai-page-ai-opencode-permissions {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 12px;
}
.wolai-page-ai-opencode-permission {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 6px;
align-items: center;
padding: 8px;
border: 1px solid rgba(227, 115, 14, 0.24);
border-radius: 10px;
background: rgba(255, 247, 237, 0.92);
font-size: 12px;
}
.wolai-page-ai-opencode-permission span {
overflow: hidden;
color: rgba(27, 28, 28, 0.62);
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-opencode-sessions-wrap {
font-size: 12px;
}
.wolai-page-ai-opencode-sessions {
display: grid;
gap: 4px;
margin-top: 6px;
}
.wolai-page-ai-opencode-session-row {
display: grid;
min-width: 0;
gap: 2px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.72);
color: inherit;
text-align: left;
}
.wolai-page-ai-opencode-session-row[data-active="true"] {
border-color: rgba(31, 111, 235, 0.38);
background: rgba(31, 111, 235, 0.08);
}
.wolai-page-ai-opencode-session-row span,
.wolai-page-ai-opencode-message-role span {
overflow: hidden;
color: rgba(27, 28, 28, 0.52);
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-opencode-message-role {
display: flex;
justify-content: space-between;
gap: 8px;
}
.wolai-page-ai-opencode-part {
margin-top: 8px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 10px;
padding: 8px;
background: rgba(250, 250, 249, 0.88);
font-size: 12px;
}
.wolai-page-ai-opencode-part summary {
cursor: pointer;
font-weight: 650;
}
.wolai-page-ai-opencode-part pre,
.wolai-page-ai-opencode-error {
margin: 8px 0 0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.wolai-page-ai-opencode-tool summary {
display: flex;
justify-content: space-between;
gap: 8px;
}
.wolai-page-ai-opencode-patch {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.wolai-page-ai-opencode-patch button,
.wolai-page-ai-opencode-part button {
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 999px;
padding: 3px 8px;
background: #fff;
}
.wolai-page-ai-opencode-todo {
margin: 0;
padding: 0 12px;
list-style-position: inside;
font-size: 12px;
}
@@ -456,6 +456,44 @@
border-top: 1px solid rgba(27, 28, 28, 0.06);
}
/* 账号下登录态(扩展 Cookie session)状态徽章 */
.mnote-vault-session-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.01em;
}
.mnote-vault-session-badge.is-saved {
background: rgba(34, 160, 107, 0.12);
color: #1a7a4c;
border: 1px solid rgba(34, 160, 107, 0.28);
}
.mnote-vault-session-badge.is-absent {
background: rgba(109, 106, 101, 0.08);
color: #6d6a65;
border: 1px solid rgba(109, 106, 101, 0.18);
}
.mnote-vault-session-badge.is-compact {
font-size: 10px;
padding: 0 6px;
line-height: 16px;
margin-left: 6px;
}
.mnote-vault-session-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.mnote-vault-session-row > label {
min-width: 0;
}
.mnote-vault-field-row > label {
padding-top: 4px;
color: #6d6a65;
@@ -556,6 +594,114 @@
line-height: 18px;
}
/* Expand-style folder picker: top-level groups stay visible (no long select). */
.mnote-vault-folder-picker {
width: 100%;
box-sizing: border-box;
max-height: 220px;
overflow: auto;
padding: 6px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 6px;
background: #fafaf9;
}
.mnote-vault-folder-picker-none {
display: block;
width: 100%;
margin: 0 0 4px;
padding: 5px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: #6d6a65;
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.mnote-vault-folder-picker-none:hover,
.mnote-vault-folder-picker-label:hover {
background: rgba(35, 131, 226, 0.08);
color: #37352f;
}
.mnote-vault-folder-picker-none.is-selected,
.mnote-vault-folder-picker-row.is-selected .mnote-vault-folder-picker-label {
background: rgba(35, 131, 226, 0.14);
color: #1b64c2;
font-weight: 600;
}
.mnote-vault-folder-picker-node {
min-width: 0;
}
.mnote-vault-folder-picker-row {
display: flex;
align-items: center;
gap: 2px;
min-height: 26px;
padding-left: calc(var(--vault-picker-depth, 0) * 12px);
border-radius: 4px;
}
.mnote-vault-folder-picker-chevron {
flex: 0 0 18px;
width: 18px;
height: 22px;
margin: 0;
padding: 0;
border: none;
background: transparent;
color: #9b9a97;
font: inherit;
font-size: 9px;
line-height: 22px;
text-align: center;
cursor: pointer;
}
.mnote-vault-folder-picker-chevron.is-leaf {
cursor: default;
visibility: hidden;
}
.mnote-vault-folder-picker-label {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 4px 6px;
border: none;
border-radius: 4px;
background: transparent;
color: #37352f;
font: inherit;
font-size: 13px;
line-height: 18px;
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-vault-folder-picker-children {
min-width: 0;
}
.mnote-vault-folder-picker-empty {
padding: 6px 8px;
font-size: 12px;
}
.mnote-vault-folder-hint {
margin: 0;
font-size: 11px;
color: #9b9a97;
}
.mnote-vault-folder-controls input[type="text"] {
width: 100%;
box-sizing: border-box;
+2 -79
View File
@@ -1,15 +1,12 @@
#!/usr/bin/env node
/**
* 热启动 mnote-web 单入口以及按需启用的 FastAPI / opencode
* 热启动 mnote-web 单入口以及按需启用的 FastAPI
* Page AI 仅走 Pi Lab/api/page-ai/pi/*不再启动 OpenCode
* 可使用以下环境变量调整行为
* - ENABLE_BACKEND设为 "1" or "true" 时启用默认 FastAPI 后端
* - BACKEND_CMD覆盖 FastAPI 启动命令设置后即视为显式启用后端
* - SKIP_BACKEND设为 "1" or "true" 可强制跳过 FastAPI 后端
* - ENABLE_OPENCODE设为 "1" or "true" 时启用 opencode serve默认不启动
* - OPENCODE_CMD覆盖 opencode 启动命令设置后即视为显式启用 opencode
* - SKIP_OPENCODE设为 "1" or "true" 可强制跳过 opencode
* - MNOTE_OPENCODE_XDG_ROOT / MNOTE_OPENCODE_HOMEopencode 专用运行目录
* - 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
@@ -54,7 +51,6 @@ function resolveBackendExecutable(envName, fallbackName) {
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane";
function hasCommand(command) {
@@ -79,41 +75,6 @@ function buildDefaultBackendCommand(port) {
return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`;
}
function buildDefaultOpencodeCommand(port) {
const opencodeXdgRoot = process.env.MNOTE_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/opencode/runtime";
const opencodeHome = process.env.MNOTE_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/opencode/home";
const modelEnvNames = [
"OPENCODE_API_KEY",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_GENERATIVE_AI_API_KEY",
"DEEPSEEK_API_KEY",
"GEMINI_API_KEY",
"MISTRAL_API_KEY",
"OPENROUTER_API_KEY",
"GROQ_API_KEY",
"XAI_API_KEY",
"AZURE_OPENAI_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
];
const opencodeEnv = [
"env",
...modelEnvNames.map((name) => `-u ${name}`),
`HOME=${JSON.stringify(opencodeHome)}`,
`XDG_CONFIG_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "config"))}`,
`XDG_DATA_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "data"))}`,
`XDG_STATE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "state"))}`,
`XDG_CACHE_HOME=${JSON.stringify(path.join(opencodeXdgRoot, "cache"))}`,
].join(" ");
return [
`mkdir -p ${JSON.stringify(opencodeXdgRoot)} ${JSON.stringify(opencodeHome)}`,
`while true; do script -qfec ${JSON.stringify(`${opencodeEnv} opencode serve --hostname=127.0.0.1 --port ${port} --print-logs`)} /dev/null; sleep 1; done`,
].join(" && ");
}
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
@@ -125,12 +86,6 @@ function shouldStartBackend(env = process.env) {
return isEnabledEnv(env.ENABLE_BACKEND);
}
function shouldStartOpencode(env = process.env) {
if (isEnabledEnv(env.SKIP_OPENCODE)) return false;
if (String(env.OPENCODE_CMD || "").trim()) return true;
return isEnabledEnv(env.ENABLE_OPENCODE);
}
function resolveRuntimePlan(env = process.env) {
const frontendPort = Number(env.FRONTEND_PORT || 3000);
const skipGateway = false;
@@ -162,7 +117,6 @@ function resolveRuntimePlan(env = process.env) {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
MNOTE_KNOWLEDGE_PROVIDER: env.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy",
MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`,
...controlPlaneEnv,
},
};
@@ -195,17 +149,6 @@ const tasks = [
},
]
: []),
...(shouldStartOpencode(process.env)
? [
{
name: "opencode",
command:
process.env.OPENCODE_CMD ||
buildDefaultOpencodeCommand(opencodePortFromEnv),
cwd: rootDir,
},
]
: []),
];
function findTask(name) {
@@ -707,24 +650,6 @@ async function main() {
}
const desiredOpencodePort = opencodePortFromEnv;
if (shouldStartOpencode(process.env) && !process.env.OPENCODE_CMD) {
const opencodePortOk = await ensurePortFree(desiredOpencodePort, "opencode");
if (!opencodePortOk) {
console.error(`opencode 端口 ${desiredOpencodePort} 无法释放,已中止启动。`);
process.exit(1);
}
const opencodeTask = findTask("opencode");
if (!opencodeTask) {
throw new Error("缺少 opencode 任务配置");
}
opencodeTask.command = buildDefaultOpencodeCommand(desiredOpencodePort);
} else if (isEnabledEnv(process.env.SKIP_OPENCODE)) {
logPrefix("opencode", "已跳过 opencodeSKIP_OPENCODE=1)。");
} else if (!shouldStartOpencode(process.env)) {
}
if (tasks.length === 0) {
console.error("未配置任何可运行的任务,检查环境变量设置。");
process.exit(1);
@@ -744,7 +669,6 @@ if (require.main === module) {
}
module.exports = {
buildDefaultOpencodeCommand,
collectStaleMnoteWebCargoPids,
ensurePortFree,
getListeningPidsByPort,
@@ -757,7 +681,6 @@ module.exports = {
resolveBackendExecutable,
schedulePiLabWarmup,
shouldStartBackend,
shouldStartOpencode,
stopStaleMnoteWebCargoProcesses,
terminatePid,
};
+1 -21
View File
@@ -3,7 +3,6 @@ const { spawn } = require("node:child_process");
const net = require("node:net");
const { test } = require("node:test");
const {
buildDefaultOpencodeCommand,
collectStaleMnoteWebCargoPids,
resolveBackendExecutable,
ensurePortFree,
@@ -11,7 +10,6 @@ const {
isPortFree,
resolveRuntimePlan,
shouldStartBackend,
shouldStartOpencode,
stopStaleMnoteWebCargoProcesses,
} = require("./desktop-hot.js");
@@ -142,10 +140,10 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
MNOTE_WEB_BIND: "0.0.0.0:3000",
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy",
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db",
});
assert.equal(plan.mnoteWebEnv.MNOTE_OPENCODE_BASE_URL, undefined);
});
test("热启动计划支持 libSQL local 控制面后端", () => {
@@ -189,24 +187,6 @@ test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false);
});
test("默认跳过 opencode,只有显式开启时才启动", () => {
assert.equal(shouldStartOpencode({}), false);
assert.equal(shouldStartOpencode({ ENABLE_OPENCODE: "1" }), true);
assert.equal(shouldStartOpencode({ ENABLE_OPENCODE: "true" }), true);
assert.equal(shouldStartOpencode({ OPENCODE_CMD: "custom-opencode" }), true);
assert.equal(shouldStartOpencode({ ENABLE_OPENCODE: "1", SKIP_OPENCODE: "1" }), false);
});
test("opencode 默认命令使用 MNote 专用运行目录", () => {
const command = buildDefaultOpencodeCommand(18085);
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/opencode\/runtime/);
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/opencode\/home/);
assert.match(command, /HOME=/);
assert.match(command, /XDG_CONFIG_HOME=/);
assert.doesNotMatch(command, /已检测到现有 opencode/);
assert.match(command, /opencode serve --hostname=127\.0\.0\.1 --port 18085 --print-logs/);
});
test("isHttpHealthy 只把 2xx HTTP health 视为可复用服务", async () => {
const server = net.createServer((socket) => {
socket.once("data", () => {
-3
View File
@@ -60,8 +60,6 @@ function devHotBindAddr(env = process.env) {
}
function buildDevHotEnv(baseEnv = process.env) {
const opencodePort = String(baseEnv.OPENCODE_PORT || "4096").trim();
const opencodeBaseUrl = String(baseEnv.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).trim();
const controlPlaneBackend = String(baseEnv.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local";
if (controlPlaneBackend === "sqlite") {
throw new Error("dev:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced");
@@ -75,7 +73,6 @@ function buildDevHotEnv(baseEnv = process.env) {
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
MNOTE_OPENCODE_BASE_URL: opencodeBaseUrl,
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 ?? "0").trim() || "0",
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More