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:
@@ -2,13 +2,28 @@
|
|||||||
|
|
||||||
## 当前主线
|
## 当前主线
|
||||||
|
|
||||||
- 方向:`tree-first graph kernel` + local-first MVP 后阶段(底座统一、compat 瘦身、产品化闭环)。
|
- 产品:`tree-first graph kernel` + local-first 工作区;云首发同栈(单机/云服务器均可部署)。
|
||||||
- 产品形态:`VSCode 简化版工作区 + leptos-tiptap Markdown + Pi Rust Page AI + LightRAG + mindmap/office 插件 + Wolai 主题壳 + Turso/libSQL 控制面`。
|
- 形态:`VSCode 简化版工作区 + leptos-tiptap Markdown + Pi Lab Page AI + LightRAG + mindmap/OnlyOffice + Wolai 主题壳 + libSQL 控制面 + Vault`。
|
||||||
- 数据真相:本地 workspace folder;页面正文为 `.md`。控制面:Turso/libSQL(auth / membership / share / sync / AI policy / Pi scope)。
|
- 数据真相:本地(或服务器上的)workspace folder;页面正文为 `.md`。
|
||||||
- Page AI:Pi Lab 为 3000 默认且唯一产品级入口。ACP/Hermes/Reasonix、OpenHub、旧 `/api/hermes/*` 等为兼容/legacy,不得新增能力;退役须原子覆盖。
|
- 控制面:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local` 为默认与云首发推荐(独立部署、无需 Turso 云账号);`turso-remote` / `turso-local-replica` 仅在明确需要托管同步时使用。`sqlite` 不是 mnote-web 运行时。
|
||||||
- Web:`mnote-web` 为 3000 唯一公开入口。kernel 持有树/边/projection/command 语义;前端只消费稳定 projection。
|
- Web:`mnote-web` 为 **3000** 唯一公开入口。kernel 持有树/边/projection/command;前端只消费稳定 projection。
|
||||||
- 正文保存:`page.body.write` + 文件版本;AI 普通 Markdown:授权文件 + allowed roots + Pi patch/diff + watcher。知识库:LightRAG + `mnote.knowledge_rag.*`。
|
- Page AI:**Pi Lab**(`/api/page-ai/pi/*`)为唯一产品入口。
|
||||||
- 架构正文:只读 `ARCHITECTURE.md`(`CURRENT_ARCHITECTURE.md` 为兼容指针)。执行稿:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`。
|
- 知识库: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 视图/挂件;非真相层 |
|
| Mindmap | tree-first 视图/挂件;非真相层 |
|
||||||
| OnlyOffice | 独立页面编辑器;经附件/资源进入 |
|
| OnlyOffice | 独立页面编辑器;经附件/资源进入 |
|
||||||
| LightRAG + Pi | 知识库与 Page AI 默认融合 |
|
| LightRAG + Pi | 知识库与 Page AI 默认融合 |
|
||||||
|
| Vault | 本机/服务器密码箱;AI 经 capability token 读密 |
|
||||||
| Resource → File → Page Tree | 对象真源 → 主组织投影 → 导航投影 |
|
| Resource → File → Page Tree | 对象真源 → 主组织投影 → 导航投影 |
|
||||||
|
|
||||||
## Runtime 模块定位
|
## Runtime 模块定位
|
||||||
|
|
||||||
- Browser:`rust/crates/mnote-web/browser/`
|
- Browser:`rust/crates/mnote-web/browser/`
|
||||||
- Sidebar:`sidebar-tree-runtime.js`(及 workspace / page tree / filetree / upload 等子模块)
|
- 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`
|
- FileTree:`filetree-*.js`
|
||||||
- Page AI host:`sidebar-page-ai-runtime.js`(owner `07-ai`)
|
- Page AI host:`sidebar-page-ai-*.js`(默认 Pi Lab)
|
||||||
- 文档 host:`document-editor-adapter-runtime.js`
|
- 文档 host:`document-editor-adapter-runtime.js`
|
||||||
- debug tree shell:`tree-shell-runtime.js`(默认关闭)
|
- Island:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/`
|
||||||
- Island:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/`(勿把新行为继续堆进根 `lib.rs`,除非 wasm entry / 薄壳)
|
|
||||||
- SSR 注入:`layout.rs` / `tree.rs` / `web_shell.rs` — 非大型 JS 功能定位入口
|
- SSR 注入:`layout.rs` / `tree.rs` / `web_shell.rs` — 非大型 JS 功能定位入口
|
||||||
|
|
||||||
## 目录优先级
|
## 目录优先级
|
||||||
@@ -37,84 +52,71 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `rust/crates/core-protocol/` | Kernel 类型、projection 协议 |
|
| `rust/crates/core-protocol/` | Kernel 类型、projection 协议 |
|
||||||
| `rust/crates/bridge-runtime/` | query / command / traversal |
|
| `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/crates/mnote-web/browser/` | 浏览器 runtime JS |
|
||||||
| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island |
|
| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island |
|
||||||
| `wolai-backend/app/` | 辅助后端 / 异步 |
|
| `rust/crates/mnote-vault-core/` | Vault 核心 |
|
||||||
| `src/components/onlyoffice/` | OnlyOffice 静态与插件 |
|
| `src/components/onlyoffice/` | OnlyOffice 静态与插件 |
|
||||||
| `recycle/` | 历史回收;默认不作为实现依据 |
|
| `wolai-backend/app/` | 辅助后端 / 异步(非 3000 主入口) |
|
||||||
|
| `recycle/` | 回收区;不作为实现依据 |
|
||||||
|
| `reference-code/` | 对照参考;**不进部署包** |
|
||||||
|
|
||||||
## 架构约束
|
## 架构约束
|
||||||
|
|
||||||
- 树 / 页面结构 / 引用边:优先落 Rust kernel,禁止 UI 拼第二套真相。
|
- 树 / 页面结构 / 引用边:落 Rust kernel,禁止 UI 拼第二套真相。
|
||||||
- `compat route` 只承接过渡流量,不堆长期业务。
|
- 浏览器主链禁止新增 `setInterval` / 周期轮询;用 WS/SSE、watcher、command result、`MutationObserver`。
|
||||||
- 浏览器主链禁止新增 `setInterval` / 周期轮询刷新(树、文档、附件、AI 面板等);用 WS/SSE、watcher、command result、`MutationObserver`。legacy/debug 若保留轮询须写明原因与退出条件。
|
- 标题 / 页面设置 / 正文:收口 Page Aggregate。
|
||||||
- 标题 / 页面设置 / 正文:收口 Page Aggregate;不在壳外侧拼第二份页面真相。
|
- 新建/重命名/移动/归档:`tree.*`。
|
||||||
- 新建/重命名/移动/归档:`tree.*`;`documents.*` 仅兼容。
|
- AI 正文:文件引用 + `AiAccessScope` + Pi patch/diff + 版本冲突 + watcher。Phase C 流式 apply 冻结。
|
||||||
- local-first AI 正文:文件引用 + `AiAccessScope` + Pi patch/diff + 版本冲突 + watcher;`mnote.doc.*` / `mnote.block.*` 仅 cloud/remote/compat 或结构辅助。Phase C 流式 apply 冻结。
|
- Page AI 宿主为原生 Pi Lab runtime;普通 UI 不得出现第二套 agent 切换入口。
|
||||||
- Page AI / 知识库首屏:对话与任务区优先;debug/health 放折叠区。
|
- Agent 工具包:`rust/crates/mnote-web/src/mnote_agent_tools/`(HTTP 面 `/api/mnote/tools/*`;Pi / LightRAG / skill / OnlyOffice live tools 依赖,**不可整目录误删**)。
|
||||||
- Page AI 宿主改原生 Pi Lab runtime;禁止 DOM 注入伪装第三方;普通 UI 不得出现 fallback 切换入口。
|
- 架构判断:`ARCHITECTURE.md` → 对应 `design/**` process/done 稿。
|
||||||
- 知识库:LightRAG + `mnote.knowledge_rag.*` / `/api/knowledge-rag/*`。
|
|
||||||
- 架构判断优先:`ARCHITECTURE.md`,再查对应 `design/**` process/done 稿。
|
|
||||||
|
|
||||||
## 设计稿目录规则
|
## 设计稿目录规则
|
||||||
|
|
||||||
- `design/01-*` … `design/10-*`:`process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。
|
- `design/01-*` … `design/12-*`:`process/` 可执行,`done/` 已完成,`reference/` 参考,`draft/` 未成形。
|
||||||
- 已完成主线稿必须迁入 `done/`;被覆盖且非 done 的旧稿迁 `design/old/` 并标 `[recycle]`。
|
- 已完成主线稿迁 `done/`;被覆盖旧稿迁 `design/old/` 并标 `[recycle]`。
|
||||||
- `design/90-reference/` 只放参考资料。
|
- `design/90-reference/` 只放参考资料。
|
||||||
|
|
||||||
## Bugs 目录规则
|
## Bugs 目录规则
|
||||||
|
|
||||||
- 镜像 design 大类;`process/` 未修完,`done/` 已验证。
|
- 镜像 design 大类;`process/` 未修完,`done/` 已验证。
|
||||||
- 按真正 owner 归类:壳/Sidebar/文档页体验 → `05-editor-mainline/`;projection/selection/DnD/tree command → `04-tree-domain/`。
|
- 壳/Sidebar/文档页体验 → `05-editor-mainline/`;projection/selection/DnD/tree → `04-tree-domain/`。
|
||||||
- MVP 阶段发现 bug 时判断是否系统缺口;边界不清先问用户。
|
|
||||||
|
|
||||||
## 协作边界
|
## 协作边界
|
||||||
|
|
||||||
- 只改与任务直接相关的文件;不覆盖用户已有改动;无关脏改动保持不动。
|
- 只改与任务直接相关的文件;不覆盖用户已有改动;无关脏改动保持不动。
|
||||||
- UI 异常先查 debug shell / compat / 轮询是否混入主链。
|
- Subagent 为受控叶子:限定读写范围与验证命令;主控复核 diff/证据后才采纳。
|
||||||
- 发现轮询先评估事件驱动替代,再决定是否保留临时 fallback。
|
- 同一写入范围同时只允许一个 writer。
|
||||||
- Subagent 为受控叶子:任务书限定读写范围、验证命令、交付与禁止项;不得再启 runner/worktree 或改任务书。主控复核 diff/证据后才采纳。
|
|
||||||
- 同一写入范围同时只允许一个 writer(主控或一个 worker);写入范围失效须先取消 worker 再改。
|
|
||||||
- 最终测试/回复前确认实现型 subagent 已完成、已取消,或输出已标不采纳。
|
|
||||||
|
|
||||||
## CodeGraph
|
## CodeGraph
|
||||||
|
|
||||||
- 结构性代码问题优先 CodeGraph MCP(search / callers / callees / impact / explore)。
|
- 结构性代码问题优先 CodeGraph MCP(explore / impact)。
|
||||||
- 改代码后 `codegraph sync .`;大重构或索引异常 `codegraph index . --force`。
|
- 改代码后 `codegraph sync .`;大重构或索引异常 `codegraph index . --force`。
|
||||||
- 提交前再 sync,确认无 pending;字面文本/日志字符串才优先用 `rg`。
|
- 字面文本/日志字符串优先 `rg`。
|
||||||
|
|
||||||
## Reference-Code
|
## Reference-Code
|
||||||
|
|
||||||
- 优先 `reference-code/sidex-main`(完整 VSCode 工作台);`reference-code/vscode` 为裁剪辅助。
|
- 优先 `reference-code/sidex-main`;`reference-code/vscode` 为裁剪辅助。
|
||||||
- 每次只对照一个切面;区分可直接采用的交互模型 vs 不符合 local-first/tree-first 的细节。
|
- 每次只对照一个切面;不要把参考项目 UI 状态当成 MNote 新事实源。
|
||||||
- 不要把参考项目 UI 状态当成 MNote 新事实源。
|
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
- 热启动:`npm run desktop:hot` → `http://localhost:3000`
|
- 热启动:`npm run desktop:hot` → `http://localhost:3000`
|
||||||
- 测试:`cargo test -p mnote-web`
|
- 测试:`cargo test -p mnote-web`
|
||||||
- 后端:`cd wolai-backend && uvicorn app.main:app --reload --port 8000`
|
- Control-plane 默认:`MNOTE_CONTROL_PLANE_BACKEND=libsql-local`
|
||||||
- Control-plane:默认 `MNOTE_CONTROL_PLANE_BACKEND=libsql-local`;云端用 `turso-remote` / `turso-local-replica` + URL/token。`sqlite` 非 mnote-web 运行时。
|
- 云首发:同 `libsql-local` + 服务器本地数据目录;需要托管再换 `turso-remote`
|
||||||
|
|
||||||
## Smoke 与测试账号
|
## Smoke 与测试账号
|
||||||
|
|
||||||
- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + Turso/libSQL auth`。
|
- 基线:`scripts/TESTING_REFERENCE.md`;默认入口 `3000 + leptos-tiptap + local-first + libSQL auth`。
|
||||||
- 禁止 smoke 用 `sqlite3` CLI 直写 control-plane;seed 走 Rust API / `scripts/lib/control-plane-dev-seed.js` / `control-plane-test-env.js`。
|
- 禁止 smoke 用 `sqlite3` CLI 直写 control-plane;seed 走 Rust API / `scripts/lib/control-plane-dev-seed.js`。
|
||||||
- 测试账号:`mnote.e2e@example.com` / `MnoteE2E123!` / `mnote-e2e`;优先 `/auth`「测试账号快速登录」,勿用 `MNOTE_DEV_AUTH=1` 跳过真实 auth。
|
- 测试账号:`mnote.e2e@example.com` / `MnoteE2E123!` / `mnote-e2e`;优先 `/auth`「测试账号快速登录」。
|
||||||
|
|
||||||
## 前端测试
|
## 前端测试
|
||||||
|
|
||||||
- 看真实渲染:`/doko`;交互回归:浏览器自动化。
|
- 真实渲染:`/doko`;交互回归:浏览器自动化。
|
||||||
- 主页/Sidebar/文档首屏优先复用 `scripts/task*-smoke.js`。
|
- 主页/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(薄指针)
|
## 密码箱 / agent skills(薄指针)
|
||||||
|
|
||||||
- 密码箱策略 SSOT:`/home/lix/.agent-infra/vault-policy.md`;skill `$mnote-vault`(`skills/mnote-vault`,全局 symlink 到 Codex/Hermes/Grok)。
|
- 策略 SSOT:`/home/lix/.agent-infra/vault-policy.md`;skill `$mnote-vault`(`skills/mnote-vault`)。
|
||||||
- Page AI pack 注册:`hermes_tools/skill.rs` id `mnote-vault`(仅放 `skills/` 不够)。
|
- Page AI pack 注册:agent tool pack 内 `skill` 模块 id `mnote-vault`(仅放 `skills/` 不够)。
|
||||||
- 勿在本文件复制完整 vault 策略;Paseo 经 `daemon.appendSystemPrompt` 全 agent 注入短段。
|
- 勿在本文件复制完整 vault 策略。
|
||||||
|
|||||||
+83
-87
@@ -1,113 +1,116 @@
|
|||||||
# MNote 架构
|
# MNote 架构
|
||||||
|
|
||||||
> 更新时间:2026-07-19
|
> 更新时间:2026-07-25
|
||||||
> 本文只描述**当前**产品形态、运行分层与收口缺口。历史迁移、退役路径与 changelog 不在此展开;协作规则见 `AGENTS.md`,执行 checklist 见 `design/`。
|
> 本文只描述**当前**产品形态与运行分层。协作规则见 `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. 产品形态
|
## 1. 产品形态
|
||||||
|
|
||||||
```text
|
```text
|
||||||
MNote = VSCode 简化版工作区
|
MNote = VSCode 简化版工作区
|
||||||
+ tiptap Markdown 编辑器(leptos-tiptap island)
|
+ tiptap Markdown 编辑器(leptos-tiptap island)
|
||||||
+ Pi Rust Page AI
|
+ Pi Lab Page AI
|
||||||
+ LightRAG 知识库
|
+ LightRAG 知识库
|
||||||
+ simplemindmap / OnlyOffice 插件
|
+ simplemindmap / OnlyOffice
|
||||||
|
+ Vault 密码箱
|
||||||
+ Wolai 主题 Web 壳
|
+ Wolai 主题 Web 壳
|
||||||
+ Turso/libSQL 鉴权控制面
|
+ libSQL 控制面(默认 libsql-local)
|
||||||
```
|
```
|
||||||
|
|
||||||
| 层 | 当前真源 / Owner |
|
| 层 | 当前真源 / Owner |
|
||||||
|----|------------------|
|
|----|------------------|
|
||||||
| 工作区数据 | 本地 workspace folder;页面正文为 `.md` |
|
| 工作区数据 | workspace folder;页面正文为 `.md` |
|
||||||
| 树 / 资源语义 | Rust kernel(`core-protocol` + `bridge-runtime`) |
|
| 树 / 资源语义 | Rust kernel(`core-protocol` + `bridge-runtime`) |
|
||||||
| Web 入口 | `mnote-web`,公开端口 **3000** |
|
| Web 入口 | `mnote-web`,公开端口 **3000** |
|
||||||
| 控制面 | Turso/libSQL:auth、membership、share、sync state、AI policy / Pi scope |
|
| 控制面 | libSQL/Turso store:auth、membership、share、sync state、AI policy / Pi scope |
|
||||||
| Page AI | Pi Lab(`/api/page-ai/pi/*`),默认唯一产品级入口 |
|
| Page AI | Pi Lab(`/api/page-ai/pi/*`) |
|
||||||
| 知识库 | LightRAG + `mnote.knowledge_rag.*` facade |
|
| 知识库 | LightRAG + `mnote.knowledge_rag.*` |
|
||||||
| 编辑投影 | Page Aggregate(`mnote.page_aggregate.v1`);tiptap 为显示层,非对象真源 |
|
| 密码箱 | Vault(store + 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. 分层
|
||||||
|
|
||||||
### 2.1 Workspace / Storage
|
### 2.1 Workspace / Storage
|
||||||
|
|
||||||
- 默认 source:`local_folder`
|
- 默认 source:`local_folder`(本机或服务器磁盘路径)
|
||||||
受管根:`/mnt/Data1T/Mnote_data/users/<actor>/workspaces/my-space/`
|
- 管理员通过控制面授权可读写目录
|
||||||
- 管理员通过控制面授权可读写目录;普通用户不能自助全盘读写
|
- 页面内上传默认 sibling assets;正文存相对 Markdown 链接
|
||||||
- 页面内上传默认写入 sibling assets(如 `README.assets/image.png`),正文存相对 Markdown 链接
|
- `.md` / 附件 / mindmap / OnlyOffice **不**迁入 control-plane DB
|
||||||
- 本地 `.md` / 附件 / mindmap / OnlyOffice **不**迁入 control-plane DB
|
|
||||||
|
|
||||||
### 2.2 Kernel / Projection
|
### 2.2 Kernel / Projection
|
||||||
|
|
||||||
- `core-protocol`:树、资源、页面、AI access scope、page body write 等协议
|
- `core-protocol`:树、资源、页面、AI access scope、page body write 等协议
|
||||||
- `bridge-runtime` + `mnote-web`:LocalFS(及显式 cloud/compat source)→ 稳定 projection / command
|
- `bridge-runtime` + `mnote-web`:LocalFS → 稳定 projection / command
|
||||||
- 前端只消费:`file_tree`、`page_tree`、`page_aggregate`、tree command result、少量 editor runtime payload
|
- 前端消费:`file_tree`、`page_tree`、`page_aggregate`、tree command result
|
||||||
|
|
||||||
树域三层:
|
|
||||||
|
|
||||||
| 层 | 角色 |
|
| 层 | 角色 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| Resource Tree | kernel 对象组织真源(page / mindmap / attachment / onlyoffice / …) |
|
| Resource Tree | kernel 对象组织真源 |
|
||||||
| File Tree | 主组织投影;页面正文行为 `{title}.md` |
|
| File Tree | 主组织投影;页面正文 `{title}.md` |
|
||||||
| Page Tree | 导航投影;不持有结构真相 |
|
| Page Tree | 导航投影;不持有结构真相 |
|
||||||
|
|
||||||
正式命令面:`tree.*`(如 `tree.node.create` / `rename` / `tree.subtree.move`)。`documents.*` 仅为兼容层。
|
正式命令面:`tree.*`。
|
||||||
|
|
||||||
### 2.3 Web Shell / Editor
|
### 2.3 Web Shell / Editor
|
||||||
|
|
||||||
- Owner:`mnote-web`(Rust SSR + browser runtime)
|
- Owner:`mnote-web`(Rust SSR + browser runtime)
|
||||||
- 文档页默认编辑 host:页面内 `leptos-tiptap` island
|
- 文档页:`leptos-tiptap` island + `browser/*.js`
|
||||||
- browser JS:`rust/crates/mnote-web/browser/*.js`
|
- 正文保存:`page.body.write` / `/api/page-body/write`(文件版本)
|
||||||
- island:`rust/spikes/leptos-tiptap-spike/src/editor_runtime/*.rs`
|
- Watcher:clean 自动刷新;dirty 冲突态,不静默覆盖
|
||||||
- `layout.rs` / `tree.rs` / `web_shell.rs` 只做 SSR / bootstrap / route / asset 注入
|
- Realtime:WS `/api/realtime/ws` + SSE `/api/tree/events`
|
||||||
- 正文保存主入口:`page.body.write` / `/api/page-body/write`(带文件版本);`/api/documents/save` 为 compat
|
- Mindmap:视图/挂件;OnlyOffice:独立编辑器经资源进入
|
||||||
- Watcher:clean editor 自动刷新;dirty editor 进入冲突态,不静默覆盖
|
|
||||||
- Realtime:WS 主链 `/api/realtime/ws` + SSE fallback `/api/tree/events`(snapshot / delta / resync)
|
|
||||||
- Mindmap:tree-first 的视图 / 挂件,非对象真源
|
|
||||||
- OnlyOffice:独立页面型编辑器,经附件/资源跳转进入
|
|
||||||
|
|
||||||
### 2.4 Page Aggregate
|
### 2.4 Page Aggregate
|
||||||
|
|
||||||
- 读取主链:Rust `mnote.page_aggregate.v1` snapshot(`/api/page-aggregate/:id`)
|
- 读取:`mnote.page_aggregate.v1`(`/api/page-aggregate/:id`)
|
||||||
- 输出含 `blockDocument` / `blockProjectionVersion` / `projectionSource`
|
- 本地正文真相是 `.md`;aggregate 是投影
|
||||||
- 本地正文真相是 `.md`;aggregate 是投影,不是第二份正文库
|
|
||||||
- 仍属过渡态:block projection 多从 markdown content 投影;客户端仍有 `PageAggregateClientState` reducer
|
|
||||||
|
|
||||||
### 2.5 Page AI(Pi Rust)
|
### 2.5 Page AI(Pi Lab)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
当前页 → 真实 .md
|
当前页 → 真实 .md
|
||||||
→ AiAccessScope / allowed roots / selection
|
→ AiAccessScope / allowed roots / selection
|
||||||
→ Pi Rust Page AI(/api/page-ai/pi/*)
|
→ Pi Lab(/api/page-ai/pi/*)
|
||||||
→ agent 原生 patch/diff 写文件
|
→ agent patch/diff 写文件
|
||||||
→ watcher / BufferStore / refresh → Page Aggregate + tiptap
|
→ watcher / BufferStore → Page Aggregate + tiptap
|
||||||
```
|
```
|
||||||
|
|
||||||
- Pi session 元数据 / run / tool event 默认落 control-plane;Pi JSONL 仅为 runtime 工作副本
|
- Session / run / tool event 默认落 control-plane;Pi JSONL 为 runtime 工作副本
|
||||||
- `mnote.doc.*` / `mnote.block.*` / `mnote.page.*`:cloud / remote / compat / 复杂结构辅助
|
- Agent 工具 HTTP:`/api/mnote/tools/{manifest,call,audit}`
|
||||||
- `/api/page-ai/block-edit-workflow`:非 local-first 默认正文路径
|
- 工具实现目录:`rust/crates/mnote-web/src/mnote_agent_tools/`
|
||||||
- 流式 apply + suggest/review:**Phase C 冻结,当前不实施**
|
- Pi 直接依赖其中 `knowledge_rag` 的 agent 输出整形,以及 skill pack(含 `mnote-vault`)
|
||||||
- 遗留 ACP / Hermes / Reasonix / OpenCode:兼容面,默认主壳不注入;不得新增能力;退役须原子覆盖 sidebar、route、runtime、wrapper(`hermes_tools` 中 provider-neutral 契约按实际消费者保留)
|
- 流式 apply + suggest/review:Phase C 冻结
|
||||||
|
|
||||||
### 2.6 Knowledge RAG(LightRAG)
|
### 2.6 Knowledge RAG(LightRAG)
|
||||||
|
|
||||||
- 资料 ingestion / query / citation / open-reference 主路径:LightRAG
|
- ingestion / query / citation / open-reference 主路径:LightRAG
|
||||||
- MNote 只维护 source registry、权限、FileTree 灯号、status UI、citation 映射、tool facade
|
- MNote 维护 source registry、权限、FileTree 灯号、status UI、citation 映射、tool facade
|
||||||
- Agent 工具:`mnote.knowledge_rag.status` / `query` / `open_reference`
|
- Agent 工具:`mnote.knowledge_rag.status` / `query` / `section_context` / `open_reference`
|
||||||
- 不把 provider storage 当用户正文真相;不复制图谱/chunk 为第二套索引
|
|
||||||
|
|
||||||
### 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
|
```text
|
||||||
ControlPlaneStore
|
ControlPlaneStore
|
||||||
├── TursoControlPlaneStore mnote-web 唯一运行时
|
└── TursoControlPlaneStore mnote-web 唯一运行时
|
||||||
│ ├── libsql-local 默认
|
├── libsql-local 默认 / 云首发推荐
|
||||||
│ ├── turso-remote
|
├── turso-remote 可选托管
|
||||||
│ ├── turso-local-replica
|
├── turso-local-replica
|
||||||
│ └── turso-synced
|
└── turso-synced
|
||||||
└── SqliteControlPlaneStore 仅 admin 迁移/导出与测试隔离
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| 变量 | 用途 | 默认 |
|
| 变量 | 用途 | 默认 |
|
||||||
@@ -116,59 +119,52 @@ ControlPlaneStore
|
|||||||
| `MNOTE_TURSO_LOCAL_PATH` | local 文件 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db` |
|
| `MNOTE_TURSO_LOCAL_PATH` | local 文件 | `/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db` |
|
||||||
| `MNOTE_TURSO_DATABASE_URL` | remote URL | — |
|
| `MNOTE_TURSO_DATABASE_URL` | remote URL | — |
|
||||||
| `MNOTE_TURSO_AUTH_TOKEN` | remote token | — |
|
| `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
|
- 只存元数据:users、sessions、workspaces、directory grants、share、sync state、AI policy/runtime、audit
|
||||||
- `sqlite` 不得作为 mnote-web runtime;启动链拒绝
|
- `sqlite` 不得作为 mnote-web runtime
|
||||||
- 备份:`control-plane-admin export-target-to-sqlite` → 再导入 libSQL/Turso,不回退 SQLite 运行
|
- 备份:admin export → 再导入 libSQL;不回退 SQLite 运行
|
||||||
|
|
||||||
### 2.8 代码入口
|
### 2.9 代码入口
|
||||||
|
|
||||||
| 路径 | 职责 |
|
| 路径 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `rust/crates/core-protocol/` | Kernel 类型与 projection 协议 |
|
| `rust/crates/core-protocol/` | Kernel 类型与 projection 协议 |
|
||||||
| `rust/crates/bridge-runtime/` | query / command / traversal |
|
| `rust/crates/bridge-runtime/` | query / command / traversal |
|
||||||
| `rust/crates/mnote-web/` | 3000 gateway、SSR shell、API、compat、realtime |
|
| `rust/crates/mnote-web/` | 3000 gateway、SSR、API、realtime |
|
||||||
| `rust/crates/mnote-web/browser/` | Sidebar / FileTree / document adapter / Page AI host JS |
|
| `rust/crates/mnote-web/browser/` | Sidebar / FileTree / document / Page AI host |
|
||||||
| `rust/spikes/leptos-tiptap-spike/` | 文档页默认编辑 island |
|
| `rust/spikes/leptos-tiptap-spike/` | 文档页编辑 island |
|
||||||
| `wolai-backend/app/` | 辅助后端 / 异步处理 |
|
| `rust/crates/mnote-vault-core/` | Vault 核心 |
|
||||||
| `src/components/onlyoffice/` | OnlyOffice 静态与插件资源 |
|
| `src/components/onlyoffice/` | OnlyOffice 静态资源 |
|
||||||
|
|
||||||
`recycle/` 为历史回收区,不作为当前实现依据。
|
|
||||||
|
|
||||||
## 3. 当前收口缺口
|
## 3. 当前收口缺口
|
||||||
|
|
||||||
阶段:local-first **MVP 后**。底座与主路径已立;重点是统一与产品化,不是扩 compat 或旧主链。
|
阶段:local-first **MVP 后**;重点是统一与产品化,以及预发前去掉非首发代码面。
|
||||||
|
|
||||||
1. **WorkspacePath / ObjectIdentity** — 路径、identity、资源归属统一到 kernel contract
|
1. **WorkspacePath / ObjectIdentity** — 路径与 identity 统一到 kernel
|
||||||
2. **DocumentBuffer / BufferStore** — runtime 已有;多 tab 仲裁与冲突 UI 体验收口
|
2. **DocumentBuffer / BufferStore** — 多 tab 仲裁与冲突 UI
|
||||||
3. **Page Aggregate** — 瘦身 compat join;退役 ClientState 混合 reducer,以 Rust snapshot 为单一运行时真相
|
3. **Page Aggregate** — 以 Rust snapshot 为单一运行时真相
|
||||||
4. **tree command context** — 菜单 / 快捷键 / AI enablement 同一 context,勿各处硬编码
|
4. **tree command context** — 菜单 / 快捷键 / AI enablement 同一 context
|
||||||
5. **tree live cache** — watcher + WS/SSE 收进同一 cache,减补偿链
|
5. **tree live cache** — watcher + WS/SSE 收进同一 cache
|
||||||
6. **管理员目录授权 UI** — grant / access-policy 可管理
|
6. **管理员目录授权 UI**
|
||||||
7. **冲突合并 UI** — 接受磁盘 / 保留编辑器 / diff 合并
|
7. **冲突合并 UI**
|
||||||
8. **agent 写入审计** — changed files、diff、run id、actor 本地落盘,可同步控制面
|
8. **agent 写入审计**
|
||||||
9. **本地轻量搜索** — workspace 全文 / 反链 / 标签,不依赖 cloud search
|
9. **本地轻量搜索**
|
||||||
10. **分享与同步闭环** — share grant ≠ 本机 filesystem root;控制面不可用时不扩权限
|
10. **分享与同步闭环**
|
||||||
11. **插件资源模型** — mindmap / office 作为 Resource Tree 对象
|
11. **插件资源模型** — mindmap / office 作为 Resource Tree 对象
|
||||||
12. **遗留兼容面退役** — ACP/Hermes/Reasonix/OpenCode 原子删除;Convex 仅显式 cloud/compat
|
12. **预发瘦身** — 删除非 Pi 的 agent 宿主与巨型兼容网关;工具包重命名;见 `22-pre-release-legacy-purge-v1.md`
|
||||||
|
|
||||||
执行稿:`design/10-review/process/21-mvp-post-architecture-closure-checklist-v1.md`
|
|
||||||
|
|
||||||
## 4. 设计索引(当前)
|
## 4. 设计索引(当前)
|
||||||
|
|
||||||
| 主题 | 文档 |
|
| 主题 | 文档 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 预发瘦身 | `design/10-review/process/22-pre-release-legacy-purge-v1.md` |
|
||||||
| 总收口 | `design/10-review/process/21-mvp-post-architecture-closure-checklist-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` |
|
| 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` |
|
| control-plane | `design/02-convex-rust-long-term-architecture/process/2-9-turso-control-plane-cutover-v1.md` |
|
||||||
| Page Aggregate | `design/05-editor-mainline/reference/5-5-*.md`、`done/5-6-*.md` |
|
| Page Aggregate | `design/05-editor-mainline/` |
|
||||||
| AI 文件编辑 | `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` |
|
| 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` |
|
| 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` |
|
| Vault | `design/12-vault/` |
|
||||||
| Resource / File / Page Tree | `design/04-tree-domain/done/4-24-*.md` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# 22 · 预发 Legacy 瘦身清单 v1
|
||||||
|
|
||||||
|
> 状态:**process → 执行完成(Wave 0–9 出口已达)**
|
||||||
|
> 日期:2026-07-25
|
||||||
|
> 目标:云首发前去掉非产品主链代码与叙事,减小二进制/认知负担;**不误伤 Pi Lab / LightRAG / OnlyOffice / Vault**。
|
||||||
|
> 控制面云首发:`libsql-local`(独立部署)。
|
||||||
|
> 产品 Page AI 唯一主链:**Pi Lab(Rust 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/` | ~412KB(tool 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-retire(exit 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-retire;fixture 字面量中性化 | ✅ |
|
||||||
|
|
||||||
|
## 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 404(2026-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/9:browser 死代码删除 + schema 中性化(`mnote.agent_tool*.v1`、skill 仅 `pi`/`chat_only`)
|
||||||
|
- [x] Wave 10:smoke 工具路径 → `/api/mnote/tools`;hermes-client/opencode 死 smoke soft-retire;search/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 9(schema 中性化,已完成)**:
|
||||||
|
- `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(本机 :3000,2026-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 10(smoke / 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)
|
||||||
|
- fixture:search `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.1(desktop-hot 去 OpenCode,2026-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 0–10 / 10.1 已完成;全量 lib 测试绿;主链 E2E smoke 绿;活跃 smoke 已切到 `/api/mnote/tools`,死 hermes/opencode smoke 已 soft-retire;desktop-hot 不再启动 OpenCode。
|
||||||
@@ -94,7 +94,8 @@ AI 不得用通用 file 工具扫 vault,只能走分层 `mnote.vault.*`(P1
|
|||||||
- 不做跨 workspace 云端 vault 同步。
|
- 不做跨 workspace 云端 vault 同步。
|
||||||
- 不把 vault 做成 FileTree / Page Tree 节点或 `tree.*` 命令对象。
|
- 不把 vault 做成 FileTree / Page Tree 节点或 `tree.*` 命令对象。
|
||||||
- 不把 vault CRUD 塞进 tiptap / Page Aggregate。
|
- 不把 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 伪装。
|
- **P0 不做** 外部进程改 vault 文件后的 live FS 刷新(见 §4.2);禁止 setInterval 伪装。
|
||||||
- 不把 vault 写入 LightRAG / evidence / 全局搜索 snippet。
|
- 不把 vault 写入 LightRAG / evidence / 全局搜索 snippet。
|
||||||
- **不** 声称导入前 `个人/密码/` 已与 vault 同等安全。
|
- **不** 声称导入前 `个人/密码/` 已与 vault 同等安全。
|
||||||
@@ -223,6 +224,9 @@ accounts:
|
|||||||
email: alice@example.com
|
email: alice@example.com
|
||||||
password: <example-template>
|
password: <example-template>
|
||||||
passwordHint: "Li@[A]***"
|
passwordHint: "Li@[A]***"
|
||||||
|
# 登录态(Cookie)不写在本 frontmatter 新路径:
|
||||||
|
# 见 sessions/{id}/{accountId}.json(12-3 §5.6);
|
||||||
|
# 旧字段 loginSession 仅兼容读。
|
||||||
# 附录密钥(API Key / Token);顶层 apikey/token 为首个同类镜像
|
# 附录密钥(API Key / Token);顶层 apikey/token 为首个同类镜像
|
||||||
secrets:
|
secrets:
|
||||||
- id: sec_...
|
- id: sec_...
|
||||||
@@ -713,6 +717,7 @@ fn is_vault_sensitive_relative_path(rel: &str) -> bool {
|
|||||||
#### P2
|
#### P2
|
||||||
|
|
||||||
- 协助录入确认流、attachment cookies、自注册回写、export;按 credential/run grant(可选)。
|
- 协助录入确认流、attachment cookies、自注册回写、export;按 credential/run grant(可选)。
|
||||||
|
- 浏览器侧录入主路径收敛到 **12-3 Chrome 扩展**;工作台内粘贴助手可后置或不做。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ L-Crypto 在文件格式与迁移就绪后再做,不阻塞 agent 读路径瘦
|
|||||||
|
|
||||||
旁路(可选,非读密阻塞路径):
|
旁路(可选,非读密阻塞路径):
|
||||||
mnote-web /vault UI、human CRUD、共享到 AI 本
|
mnote-web /vault UI、human CRUD、共享到 AI 本
|
||||||
|
Chrome 扩展保存到 vault(人用 human token,见 12-3;**禁止**复用本设计 agent token)
|
||||||
|
登录态落盘:sessions/{credId}/{accountId}.json(12-3 §5.6);login/session CLI 读该路径,fallback frontmatter loginSession
|
||||||
vault-login-helper resolve 后外站登录 / session 回写
|
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 | v1 初稿:本地文件 SSOT + vaultd + agent token;与 12-1 AI 策略对齐;L-Access P0 / L-Crypto P2;验收与 PR 切片 |
|
||||||
| 2026-07-23 | P0 落地:`mnote-vault-core` + `mnote-vault` CLI(issue-token/list/get/resolve);无 web smoke 绿;UDS vaultd / web 共 core 留 P1 |
|
| 2026-07-23 | P0 落地:`mnote-vault-core` + `mnote-vault` CLI(issue-token/list/get/resolve);无 web smoke 绿;UDS vaultd / web 共 core 留 P1 |
|
||||||
| 2026-07-23 | P1d:login/session → core + CLI/UDS;web thin-wrap;session 回写 + login reuse 不依赖 mnote-web |
|
| 2026-07-23 | P1d:login/session → core + CLI/UDS;web thin-wrap;session 回写 + 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 引导**:远程地址 + 账号密码登录 + 选择 workspace(rootUri)+ 连接状态展示。
|
||||||
|
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 MV3;API 预留可移植 |
|
||||||
|
| 在 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 吊销后 401(smoke)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 Worker(background) │
|
||||||
|
│ - 持 baseUrl + human token + rootUri │
|
||||||
|
│ - 保存弹层协调 / 工具栏 badge │
|
||||||
|
│ - fetch(`${baseUrl}/api/vault/...`) │
|
||||||
|
│ - host_permissions: 用户配置的 baseUrl(optional 动态) │
|
||||||
|
└────────────────────────────┬────────────────────────────────┘
|
||||||
|
│ 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` | 重 | 备选 |
|
||||||
|
|
||||||
|
**P0:shadow 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-web,MVP 建议)
|
||||||
|
|
||||||
|
#### `POST /api/vault/extension/token`
|
||||||
|
|
||||||
|
**前置**:已登录 session(Cookie)或刚完成 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/update,reveal 仍要 web session
|
||||||
|
|
||||||
|
**推荐 P0:extension 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.6;CLI 语义不变 |
|
||||||
|
| 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;短 TTL;revoke;scope 无 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 以外 secret;session 仅 `hasLoginSession` |
|
||||||
|
| L1 | create/update 提交明文 password;session put 提交 cookie **仅** HTTPS 到 baseUrl;扩展本地不落盘 |
|
||||||
|
| L2 | **不提供** resolve;禁止 agent token;session **写**允许、**读明文**不对 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 PUT;core 写 `sessions/` |
|
||||||
|
| **P1** | get+merge `accounts[]` + 每账号 session;`login --account-id`;`/vault/match`;share-to-ai 复制 session;workbench「复制到扩展」;登录成功后二次抓 cookie | P0 |
|
||||||
|
| **P2** | 自动填充(`vault.reveal`);`resolve --field session`;提交成功智能检测;Firefox | 安全评审 |
|
||||||
|
| **P3** | Native Messaging 纯本机旁路;导入浏览器 CSV;TOTP;去掉 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 + core(session 文件)
|
||||||
|
|
||||||
|
- [x] `POST /api/vault/extension/token` + revoke
|
||||||
|
- [x] Bearer `mnext1` 解析接入 `RequestContext` / vault 鉴权
|
||||||
|
- [x] scope:view+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] Options:baseUrl / 登录 / 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 | 仅人用库有 session;AI `login` 无该 id 或无 cookie |
|
||||||
|
| S10 | 审计文件 | 有 create / session_put,无明文密码/cookie 行 |
|
||||||
|
| S11 | skill 文档 | Agent 按 skill 用 `login`/`session`,不扫 vault 文件 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Alternatives Considered
|
||||||
|
|
||||||
|
| 方案 | 结论 |
|
||||||
|
|------|------|
|
||||||
|
| 只做 bookmarklet 调 API | 无可靠 background;token 易进页面;否 |
|
||||||
|
| 浏览器原生 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 API;skill/agent 调用合同 §6.5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 批准后下一步(给实现者)
|
||||||
|
|
||||||
|
1. 产品确认 §13 D1–D5、**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,**不是**扩展通道。
|
||||||
|
|
||||||
@@ -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 永久跳过自动提示(仍可用工具栏手动保存)。
|
||||||
|
|
||||||
|
## 保存流程(P0–P1)
|
||||||
|
|
||||||
|
对齐 KeePassXC / Bitwarden 思路,**不使用 `webRequest`**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
content: form submit / 提交按钮
|
||||||
|
→ PENDING_SAVE(background 内存 + 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 session(setTimeout + 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`(P0–P1 用 `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
@@ -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 |
@@ -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];
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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/<actor>/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>
|
||||||
@@ -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();
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
Generated
+3
@@ -2424,6 +2424,8 @@ dependencies = [
|
|||||||
"control-plane",
|
"control-plane",
|
||||||
"core-protocol",
|
"core-protocol",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"hyper 1.10.1",
|
"hyper 1.10.1",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"leptos",
|
"leptos",
|
||||||
@@ -2434,6 +2436,7 @@ dependencies = [
|
|||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use crate::store::{
|
|||||||
self, append_vault_audit, ensure_vault_directories, get_credential, list_credentials,
|
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,
|
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,
|
project_list_entry_with_cipher, project_secret_revealed, put_login_playbook, put_login_session,
|
||||||
resolve_record_secret_field, resolve_secret_with_cipher_book, VaultCredentialRecord,
|
resolve_login_session, resolve_record_secret_field, resolve_secret_with_cipher_book,
|
||||||
VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
|
VaultCredentialRecord, VaultItemStatus, VaultLoginPlaybook, VaultLoginSession,
|
||||||
};
|
};
|
||||||
use crate::token::DEFAULT_ACTOR;
|
use crate::token::DEFAULT_ACTOR;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -376,6 +376,7 @@ pub fn put_ai_vault_session(
|
|||||||
expires_at: Some(expires.clone()),
|
expires_at: Some(expires.clone()),
|
||||||
last_login_at: Some(now),
|
last_login_at: Some(now),
|
||||||
source: Some(source.trim().to_string()),
|
source: Some(source.trim().to_string()),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let record = put_login_session(&root, credential_id, session)?;
|
let record = put_login_session(&root, credential_id, session)?;
|
||||||
let _ = append_vault_audit(
|
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 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) {
|
if login_session_is_fresh(sess) {
|
||||||
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
||||||
let _ = append_vault_audit(
|
let _ = append_vault_audit(
|
||||||
@@ -452,6 +456,7 @@ pub fn login_ai_vault_credential(
|
|||||||
"mode": "session",
|
"mode": "session",
|
||||||
"cookieHeader": cookie,
|
"cookieHeader": cookie,
|
||||||
"expiresAt": sess.expires_at,
|
"expiresAt": sess.expires_at,
|
||||||
|
"accountId": sess.account_id,
|
||||||
"loginPlaybook": playbook,
|
"loginPlaybook": playbook,
|
||||||
"transcriptHint": "已复用登录态",
|
"transcriptHint": "已复用登录态",
|
||||||
"note": "playbook=human_required;有可用 session 直接复用",
|
"note": "playbook=human_required;有可用 session 直接复用",
|
||||||
@@ -479,7 +484,7 @@ pub fn login_ai_vault_credential(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !force_refresh {
|
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) {
|
if login_session_is_fresh(sess) {
|
||||||
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
let cookie = sess.cookie_header.clone().unwrap_or_default();
|
||||||
let _ = append_vault_audit(
|
let _ = append_vault_audit(
|
||||||
@@ -499,6 +504,7 @@ pub fn login_ai_vault_credential(
|
|||||||
"cookieHeader": cookie,
|
"cookieHeader": cookie,
|
||||||
"expiresAt": sess.expires_at,
|
"expiresAt": sess.expires_at,
|
||||||
"source": sess.source,
|
"source": sess.source,
|
||||||
|
"accountId": sess.account_id,
|
||||||
"loginPlaybook": {
|
"loginPlaybook": {
|
||||||
"mode": playbook.mode,
|
"mode": playbook.mode,
|
||||||
"preferredAccount": playbook.preferred_account,
|
"preferredAccount": playbook.preferred_account,
|
||||||
@@ -736,6 +742,7 @@ pub fn login_ai_vault_credential(
|
|||||||
expires_at: Some(expires.clone()),
|
expires_at: Some(expires.clone()),
|
||||||
last_login_at: Some(now),
|
last_login_at: Some(now),
|
||||||
source: Some("api".into()),
|
source: Some("api".into()),
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
let _ = put_login_session(&root, credential_id, session)?;
|
let _ = put_login_session(&root, credential_id, session)?;
|
||||||
let _ = append_vault_audit(
|
let _ = append_vault_audit(
|
||||||
|
|||||||
@@ -168,8 +168,32 @@ pub struct VaultLoginPlaybook {
|
|||||||
pub human_note: Option<String>,
|
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).
|
/// 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")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct VaultLoginSession {
|
pub struct VaultLoginSession {
|
||||||
/// Full Cookie request header value (secret).
|
/// Full Cookie request header value (secret).
|
||||||
@@ -179,11 +203,44 @@ pub struct VaultLoginSession {
|
|||||||
pub expires_at: Option<String>,
|
pub expires_at: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub last_login_at: Option<String>,
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub source: Option<String>,
|
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 {
|
impl VaultLoginPlaybook {
|
||||||
pub fn default_for_credential(email: Option<&str>, username: Option<&str>) -> Self {
|
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() {
|
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 [
|
for relative in [
|
||||||
"",
|
"",
|
||||||
"entries",
|
"entries",
|
||||||
|
"sessions",
|
||||||
"attachments",
|
"attachments",
|
||||||
"trash",
|
"trash",
|
||||||
"trash/entries",
|
"trash/entries",
|
||||||
|
"trash/sessions",
|
||||||
"trash/attachments",
|
"trash/attachments",
|
||||||
] {
|
] {
|
||||||
let path = if relative.is_empty() {
|
let path = if relative.is_empty() {
|
||||||
@@ -431,6 +490,359 @@ pub fn ensure_vault_directories(workspace_root: &Path) -> Result<PathBuf, WebErr
|
|||||||
Ok(root)
|
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 {
|
pub fn now_rfc3339() -> String {
|
||||||
OffsetDateTime::now_utc()
|
OffsetDateTime::now_utc()
|
||||||
.format(&Rfc3339)
|
.format(&Rfc3339)
|
||||||
@@ -1165,6 +1577,8 @@ fn index_entry_from_record(record: &VaultCredentialRecord) -> VaultIndexEntry {
|
|||||||
revision: record.revision,
|
revision: record.revision,
|
||||||
shared_to_ai: record.shared_to_ai.clone(),
|
shared_to_ai: record.shared_to_ai.clone(),
|
||||||
shared_from: record.shared_from.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
|
has_login_session: record
|
||||||
.login_session
|
.login_session
|
||||||
.as_ref()
|
.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 `/`.
|
/// Normalize logical folder path: trim, collapse `//`, strip leading/trailing `/`.
|
||||||
pub fn normalize_folder_path(raw: Option<&str>) -> Option<String> {
|
pub fn normalize_folder_path(raw: Option<&str>) -> Option<String> {
|
||||||
let Some(s) = raw.map(str::trim).filter(|v| !v.is_empty()) else {
|
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,
|
expires_at,
|
||||||
last_login_at,
|
last_login_at,
|
||||||
source,
|
source,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2366,6 +2798,9 @@ pub fn list_credentials(
|
|||||||
.filter(|entry| entry.status == status)
|
.filter(|entry| entry.status == status)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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)));
|
items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at).then(a.title.cmp(&b.title)));
|
||||||
Ok((index, items))
|
Ok((index, items))
|
||||||
}
|
}
|
||||||
@@ -2590,6 +3025,9 @@ pub fn sync_shared_ai_copy(
|
|||||||
});
|
});
|
||||||
write_record_with_index(target_workspace, &target)?;
|
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();
|
let mut source_updated = source.clone();
|
||||||
source_updated.shared_to_ai = Some(VaultShareToAi {
|
source_updated.shared_to_ai = Some(VaultShareToAi {
|
||||||
target_actor_id: target_actor_id.to_string(),
|
target_actor_id: target_actor_id.to_string(),
|
||||||
@@ -2707,6 +3145,27 @@ pub fn share_credential_to_workspace(
|
|||||||
item.login_session = None;
|
item.login_session = None;
|
||||||
write_record_with_index(target_workspace, &item)?;
|
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;
|
let mut source_updated = source;
|
||||||
source_updated.shared_to_ai = Some(VaultShareToAi {
|
source_updated.shared_to_ai = Some(VaultShareToAi {
|
||||||
target_actor_id: target_actor_id.to_string(),
|
target_actor_id: target_actor_id.to_string(),
|
||||||
@@ -3224,6 +3683,7 @@ pub fn soft_delete_credential(
|
|||||||
if active_att.exists() {
|
if active_att.exists() {
|
||||||
let _ = move_path(&active_att, &trash_att);
|
let _ = move_path(&active_att, &trash_att);
|
||||||
}
|
}
|
||||||
|
move_sessions_dir(&vault, id, true);
|
||||||
for att in &mut record.attachments {
|
for att in &mut record.attachments {
|
||||||
if att.relative_path.starts_with("attachments/") {
|
if att.relative_path.starts_with("attachments/") {
|
||||||
att.relative_path = format!("trash/{}", att.relative_path);
|
att.relative_path = format!("trash/{}", att.relative_path);
|
||||||
@@ -3262,6 +3722,7 @@ pub fn restore_credential(
|
|||||||
if trash_att.exists() {
|
if trash_att.exists() {
|
||||||
let _ = move_path(&trash_att, &active_att);
|
let _ = move_path(&trash_att, &active_att);
|
||||||
}
|
}
|
||||||
|
move_sessions_dir(&vault, id, false);
|
||||||
for att in &mut record.attachments {
|
for att in &mut record.attachments {
|
||||||
if let Some(rest) = att.relative_path.strip_prefix("trash/") {
|
if let Some(rest) = att.relative_path.strip_prefix("trash/") {
|
||||||
att.relative_path = rest.to_string();
|
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() {
|
if trash_att.exists() {
|
||||||
let _ = fs::remove_dir_all(&trash_att);
|
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.entries.remove(id);
|
||||||
index.revision = index.revision.saturating_add(1);
|
index.revision = index.revision.saturating_add(1);
|
||||||
index.updated_at = now_rfc3339();
|
index.updated_at = now_rfc3339();
|
||||||
@@ -3444,21 +3911,41 @@ pub fn project_item_l0_with_cipher(
|
|||||||
"isSharedToAi": record.shared_to_ai.is_some(),
|
"isSharedToAi": record.shared_to_ai.is_some(),
|
||||||
"isAiSharedCopy": record.shared_from.is_some()
|
"isAiSharedCopy": record.shared_from.is_some()
|
||||||
|| record.tags.iter().any(|t| t == "ai-shared"),
|
|| record.tags.iter().any(|t| t == "ai-shared"),
|
||||||
"hasLoginSession": record
|
"hasLoginSession": false,
|
||||||
.login_session
|
"sessionExpiresAt": Value::Null,
|
||||||
.as_ref()
|
"lastLoginAt": Value::Null,
|
||||||
.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()),
|
|
||||||
});
|
});
|
||||||
|
// 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 {
|
if let Some(t) = username_template {
|
||||||
item["usernameTemplate"] = json!(t);
|
item["usernameTemplate"] = json!(t);
|
||||||
}
|
}
|
||||||
@@ -3483,15 +3970,6 @@ pub fn project_item_l0_with_cipher(
|
|||||||
"humanNote": pb.human_note,
|
"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
|
item
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3559,24 +4037,116 @@ pub fn project_list_entry(entry: &VaultIndexEntry) -> Value {
|
|||||||
project_list_entry_with_cipher(entry, None)
|
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(
|
pub fn put_login_session(
|
||||||
workspace_root: &Path,
|
workspace_root: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
session: VaultLoginSession,
|
session: VaultLoginSession,
|
||||||
) -> Result<VaultCredentialRecord, WebError> {
|
) -> 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 {
|
if record.status != VaultItemStatus::Active {
|
||||||
return Err(WebError::bad_request_code(
|
return Err(WebError::bad_request_code(
|
||||||
"vault_session_inactive",
|
"vault_session_inactive",
|
||||||
"只能给在用条目写入登录态",
|
"只能给在用条目写入登录态",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
record.login_session = Some(session);
|
|
||||||
record.updated_at = now_rfc3339();
|
// Prefer structured cookies for header when both present.
|
||||||
record.revision = record.revision.saturating_add(1);
|
if !session.cookies.is_empty() {
|
||||||
write_record_with_index(workspace_root, &record)?;
|
let rebuilt = cookie_header_from_cookies(&session.cookies);
|
||||||
Ok(record)
|
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(
|
pub fn put_login_playbook(
|
||||||
@@ -4409,4 +4979,159 @@ mod tests {
|
|||||||
assert_eq!(created.accounts[0].password.as_deref(), Some("secret"));
|
assert_eq!(created.accounts[0].password.as_deref(), Some("secret"));
|
||||||
let _ = fs::remove_dir_all(&root);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] }
|
|||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
comrak = { version = "0.52", default-features = false }
|
comrak = { version = "0.52", default-features = false }
|
||||||
|
hex = "0.4"
|
||||||
|
hmac = "0.12"
|
||||||
notify = "8.2.0"
|
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"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
zip = "2"
|
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) => {
|
const conflictSourceLabel = (session) => {
|
||||||
if (!session) return '';
|
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();
|
const runId = String(session.lastExternalWriteRunId || '').trim();
|
||||||
return runId ? `agent run ${runId}` : 'agent run';
|
return runId ? `agent run ${runId}` : 'agent run';
|
||||||
}
|
}
|
||||||
@@ -1820,7 +1822,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|
|||||||
window.addEventListener('tree:delta', handleTreeExternalChange);
|
window.addEventListener('tree:delta', handleTreeExternalChange);
|
||||||
window.addEventListener('tree:resync', handleTreeExternalChange);
|
window.addEventListener('tree:resync', handleTreeExternalChange);
|
||||||
window.addEventListener('mnote:page-ai-tool-write-completed', (event) => {
|
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) => {
|
window.addEventListener('mnote:local-upload-editor-save-completed', (event) => {
|
||||||
applyLocalUploadEditorSave(event?.detail || {});
|
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(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
while (href.includes('&')) href = href.replace(/&/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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 协议直连 Reasonix(DeepSeek 缓存优先)',
|
|
||||||
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="扩展子面板">' +
|
'<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 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="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>' +
|
'<button type="button" class="mnote-extensions-subtab" role="tab" aria-selected="false" data-extensions-subtab="tools">Agent 工具</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="mnote-extensions-subpanel" data-extensions-panel="knowledge">' +
|
'<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-subpanel" data-extensions-panel="skills" hidden>' +
|
||||||
'<div class="mnote-extensions-status" data-extensions-status="skills"><span class="mnote-extensions-loading">加载中...</span></div>' +
|
'<div class="mnote-extensions-status" data-extensions-status="skills"><span class="mnote-extensions-loading">加载中...</span></div>' +
|
||||||
'</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-subpanel" data-extensions-panel="tools" hidden>' +
|
||||||
'<div class="mnote-extensions-status" data-extensions-status="tools"><span class="mnote-extensions-loading">加载中...</span></div>' +
|
'<div class="mnote-extensions-status" data-extensions-status="tools"><span class="mnote-extensions-loading">加载中...</span></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -625,17 +621,12 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'<div class="mnote-dashboard-card" data-dashboard-card="skills">' +
|
'<div class="mnote-dashboard-card" data-dashboard-card="skills">' +
|
||||||
'<div class="mnote-dashboard-card-title">Skills</div>' +
|
'<div class="mnote-dashboard-card-title">Skills</div>' +
|
||||||
'<div class="mnote-dashboard-card-value" data-dashboard-value="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>' +
|
||||||
'<div class="mnote-dashboard-card" data-dashboard-card="mcp">' +
|
'<div class="mnote-dashboard-card" data-dashboard-card="tools">' +
|
||||||
'<div class="mnote-dashboard-card-title">MCP</div>' +
|
'<div class="mnote-dashboard-card-title">Agent 工具</div>' +
|
||||||
'<div class="mnote-dashboard-card-value" data-dashboard-value="mcp">--</div>' +
|
'<div class="mnote-dashboard-card-value" data-dashboard-value="tools">--</div>' +
|
||||||
'<div class="mnote-dashboard-card-desc">服务器 / 已连接</div>' +
|
'<div class="mnote-dashboard-card-desc">/api/mnote/tools/manifest</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>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<button type="button" class="wolai-page-settings-index-add" style="margin-top:10px" data-dashboard-action="refresh">刷新仪表盘</button>' +
|
'<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();
|
if (subtabName === 'knowledge') refreshKnowledgeExtensionsPanel();
|
||||||
else if (subtabName === 'skills') refreshSkillsExtensionsPanel();
|
else if (subtabName === 'skills') refreshSkillsExtensionsPanel();
|
||||||
else if (subtabName === 'mcp') refreshMcpExtensionsPanel();
|
|
||||||
else if (subtabName === 'tools') refreshToolsExtensionsPanel();
|
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 时刷新当前子面板
|
// 当切换到扩展 tab 时刷新当前子面板
|
||||||
function refreshExtensionsPanel() {
|
function refreshExtensionsPanel() {
|
||||||
var popover = ensurePageSettingsPopover();
|
var popover = ensurePageSettingsPopover();
|
||||||
@@ -3051,26 +3061,30 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Skills 子面板 ---
|
// --- Skills 子面板(来自 /api/mnote/tools/manifest,非历史 agent 网关) ---
|
||||||
async function refreshSkillsExtensionsPanel() {
|
async function refreshSkillsExtensionsPanel() {
|
||||||
var statusEl = document.querySelector('[data-extensions-status="skills"]');
|
var statusEl = document.querySelector('[data-extensions-status="skills"]');
|
||||||
if (!statusEl) return;
|
if (!statusEl) return;
|
||||||
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
|
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
|
||||||
try {
|
try {
|
||||||
var resp = await fetch('/api/page-ai/agent-descriptors');
|
var pack = await fetchAgentToolsManifest();
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
var data = await resp.json();
|
|
||||||
var descriptors = data.descriptors || data || [];
|
|
||||||
// 从 agent descriptor 中收集 skills 信息
|
|
||||||
var allSkills = [];
|
var allSkills = [];
|
||||||
descriptors.forEach(function(desc) {
|
pack.tools.forEach(function(tool) {
|
||||||
var caps = desc.capabilities || desc.capabilityStates || {};
|
if (!isSkillRelatedTool(tool)) return;
|
||||||
if (caps.skills && Array.isArray(caps.skills)) {
|
allSkills.push({
|
||||||
caps.skills.forEach(function(s) { allSkills.push({ name: s, source: desc.name || desc.id || 'unknown' }); });
|
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) {
|
if (!allSkills.length) {
|
||||||
allSkills.push({ name: 'skill-read (内置)', source: 'hermes' });
|
allSkills.push({ name: 'mnote.skill.read', source: 'builtin' });
|
||||||
}
|
}
|
||||||
var html = '';
|
var html = '';
|
||||||
allSkills.forEach(function(skill) {
|
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 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>';
|
'</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;
|
statusEl.innerHTML = html;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">无法加载 Skills 信息</span></div>';
|
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 工具子面板 ---
|
// --- Agent 工具子面板 ---
|
||||||
async function refreshToolsExtensionsPanel() {
|
async function refreshToolsExtensionsPanel() {
|
||||||
var statusEl = document.querySelector('[data-extensions-status="tools"]');
|
var statusEl = document.querySelector('[data-extensions-status="tools"]');
|
||||||
if (!statusEl) return;
|
if (!statusEl) return;
|
||||||
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
|
statusEl.innerHTML = '<span class="mnote-extensions-loading">加载中...</span>';
|
||||||
try {
|
try {
|
||||||
var resp = await fetch('/api/page-ai/agent-descriptors');
|
var pack = await fetchAgentToolsManifest();
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
var data = await resp.json();
|
|
||||||
var descriptors = data.descriptors || data || [];
|
|
||||||
var html = '';
|
var html = '';
|
||||||
descriptors.forEach(function(desc) {
|
if (!pack.tools.length) {
|
||||||
var tools = desc.tools || [];
|
html = '<div class="mnote-extensions-item"><span>暂无 Agent 工具信息</span></div>';
|
||||||
var name = desc.name || desc.id || 'unknown';
|
} else {
|
||||||
if (!tools.length) return;
|
|
||||||
html += '<div class="mnote-extensions-item mnote-extensions-agent-group">' +
|
html += '<div class="mnote-extensions-item mnote-extensions-agent-group">' +
|
||||||
'<div class="mnote-extensions-agent-name">' + escapeHtml(name) + '</div>';
|
'<div class="mnote-extensions-agent-name">mnote agent tools</div>';
|
||||||
tools.forEach(function(tool) {
|
pack.tools.forEach(function(tool) {
|
||||||
var toolName = tool.name || tool.function_name || '';
|
var toolName = tool.name || tool.function_name || '';
|
||||||
|
if (!toolName) return;
|
||||||
var disabled = tool.disabled ? ' (已禁用)' : '';
|
var disabled = tool.disabled ? ' (已禁用)' : '';
|
||||||
html += '<div class="mnote-extensions-tool-item">' +
|
html += '<div class="mnote-extensions-tool-item">' +
|
||||||
'<span class="mnote-extensions-tool-name">' + escapeHtml(toolName) + '</span>' +
|
'<span class="mnote-extensions-tool-name">' + escapeHtml(toolName) + '</span>' +
|
||||||
@@ -3142,8 +3122,7 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
});
|
}
|
||||||
if (!html) html = '<div class="mnote-extensions-item"><span>暂无 Agent 工具信息</span></div>';
|
|
||||||
statusEl.innerHTML = html;
|
statusEl.innerHTML = html;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.innerHTML = '<div class="mnote-extensions-item"><span class="mnote-extensions-error">无法加载 Agent 工具信息</span></div>';
|
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() {
|
function refreshDashboardPanel() {
|
||||||
var popover = ensurePageSettingsPopover();
|
var popover = ensurePageSettingsPopover();
|
||||||
// 知识库状态
|
|
||||||
refreshDashboardKnowledgeCard(popover);
|
refreshDashboardKnowledgeCard(popover);
|
||||||
// Skills
|
|
||||||
refreshDashboardSkillsCard(popover);
|
refreshDashboardSkillsCard(popover);
|
||||||
// MCP
|
refreshDashboardToolsCard(popover);
|
||||||
refreshDashboardMcpCard(popover);
|
|
||||||
// Agent runs
|
|
||||||
refreshDashboardAgentCard(popover);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDashboardKnowledgeCard(popover) {
|
async function refreshDashboardKnowledgeCard(popover) {
|
||||||
@@ -3408,49 +3382,22 @@ export function createSidebarPageSettingsRuntime(context) {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = '加载中...';
|
el.textContent = '加载中...';
|
||||||
try {
|
try {
|
||||||
var resp = await fetch('/api/page-ai/agent-descriptors');
|
var pack = await fetchAgentToolsManifest();
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
var count = pack.tools.filter(isSkillRelatedTool).length;
|
||||||
var data = await resp.json();
|
if (!count) count = 1; // mnote.skill.read 内置
|
||||||
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 内置
|
|
||||||
el.textContent = count + ' 个';
|
el.textContent = count + ' 个';
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
el.textContent = '--';
|
el.textContent = '--';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDashboardMcpCard(popover) {
|
async function refreshDashboardToolsCard(popover) {
|
||||||
var el = popover.querySelector('[data-dashboard-value="mcp"]');
|
var el = popover.querySelector('[data-dashboard-value="tools"]');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = '加载中...';
|
el.textContent = '加载中...';
|
||||||
try {
|
try {
|
||||||
var resp = await fetch('/api/hermes/mcp/servers');
|
var pack = await fetchAgentToolsManifest();
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
el.textContent = pack.tools.length + ' 个';
|
||||||
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 + ' 次';
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
el.textContent = '--';
|
el.textContent = '--';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2982,61 +2982,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
openSearchModal();
|
openSearchModal();
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
const sidebarPageAi = createSidebarPageAiRuntime({
|
// Page AI 产品入口仅 Pi Lab;facade 只桥接 open / trigger / no-op delegates。
|
||||||
buildLocalFileOpenUrl,
|
const sidebarPageAi = createSidebarPageAiRuntime({ pageUiState });
|
||||||
currentDocumentId,
|
|
||||||
currentPageAggregate,
|
|
||||||
currentPageOptions,
|
|
||||||
currentRootUri,
|
|
||||||
currentSourceKind,
|
|
||||||
escapeHtml,
|
|
||||||
cssEscape,
|
|
||||||
openLocalResourceInActiveTab,
|
|
||||||
pageUiState,
|
|
||||||
resolveWorkspaceId,
|
|
||||||
searchText,
|
|
||||||
});
|
|
||||||
const openPageAiDrawer = (...args) => sidebarPageAi.openPageAiDrawer(...args);
|
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 updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
|
||||||
|
|
||||||
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
|
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"]');
|
var pageAiTrigger = closestAction(e.target, '[data-mnote-action="open-page-ai"]');
|
||||||
if (pageAiTrigger) {
|
if (pageAiTrigger) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openPageAiDrawer();
|
// 产品面唯一 Page AI:Pi Lab(不再打开 Hermes/OpenCode legacy drawer)
|
||||||
|
if (window.createSidebarPageAiPiLabRuntime) {
|
||||||
|
window.createSidebarPageAiPiLabRuntime({});
|
||||||
|
}
|
||||||
|
window.postMessage({ source: 'mnote-sidebar', type: 'mnote:pi-lab-show' }, window.location.origin);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,12 +163,17 @@
|
|||||||
|
|
||||||
function ensureFormUi() {
|
function ensureFormUi() {
|
||||||
if (!state._formUi) {
|
if (!state._formUi) {
|
||||||
state._formUi = { openAccountIds: {}, expandedSecretPanels: {} };
|
state._formUi = {
|
||||||
|
openAccountIds: {},
|
||||||
|
expandedSecretPanels: {},
|
||||||
|
folderPickerOpen: {},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {};
|
if (!state._formUi.openAccountIds) state._formUi.openAccountIds = {};
|
||||||
if (!state._formUi.expandedSecretPanels) {
|
if (!state._formUi.expandedSecretPanels) {
|
||||||
state._formUi.expandedSecretPanels = {};
|
state._formUi.expandedSecretPanels = {};
|
||||||
}
|
}
|
||||||
|
if (!state._formUi.folderPickerOpen) state._formUi.folderPickerOpen = {};
|
||||||
return state._formUi;
|
return state._formUi;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,6 +897,8 @@
|
|||||||
kind: (s && s.kind) || 'apikey',
|
kind: (s && s.kind) || 'apikey',
|
||||||
label: (s && s.label) || '',
|
label: (s && s.label) || '',
|
||||||
value: (s && s.value) || { state: 'absent' },
|
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)) || '',
|
accountId: (s && (s.accountId || s.account_id)) || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -924,6 +931,96 @@
|
|||||||
return out;
|
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) {
|
function normalizeAccountsFromItem(item, forEdit) {
|
||||||
var topSecrets = normalizeSecretsFromItem(item);
|
var topSecrets = normalizeSecretsFromItem(item);
|
||||||
var accounts = (item && item.accounts) || [];
|
var accounts = (item && item.accounts) || [];
|
||||||
@@ -941,6 +1038,25 @@
|
|||||||
return s.accountId && s.accountId === a.id;
|
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 {
|
return {
|
||||||
id: a.id || '',
|
id: a.id || '',
|
||||||
label: a.label || '',
|
label: a.label || '',
|
||||||
@@ -952,7 +1068,14 @@
|
|||||||
: a.email || '',
|
: a.email || '',
|
||||||
passwordHint: a.passwordHint || '',
|
passwordHint: a.passwordHint || '',
|
||||||
password: a.password || { state: 'absent' },
|
password: a.password || { state: 'absent' },
|
||||||
|
// Draft-only flag from reRenderFormKeepingSlots; absent on server items.
|
||||||
|
passwordClear: !!a.passwordClear,
|
||||||
secrets: nested,
|
secrets: nested,
|
||||||
|
hasLoginSession: hasSess,
|
||||||
|
lastLoginAt: lastLogin,
|
||||||
|
sessionUpdatedAt: sessUpdated,
|
||||||
|
sessionExpiresAt: sessExpires,
|
||||||
|
sessionSource: a.sessionSource || '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -974,6 +1097,13 @@
|
|||||||
passwordHint: (item && item.passwordHint) || '',
|
passwordHint: (item && item.passwordHint) || '',
|
||||||
password: (item && item.password) || { state: 'absent' },
|
password: (item && item.password) || { state: 'absent' },
|
||||||
secrets: topSecrets,
|
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: '',
|
passwordHint: '',
|
||||||
password: { state: 'absent' },
|
password: { state: 'absent' },
|
||||||
secrets: [],
|
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.username ||
|
||||||
acc.email ||
|
acc.email ||
|
||||||
'账号 ' + (idx + 1);
|
'账号 ' + (idx + 1);
|
||||||
|
var sess = accountSessionView(
|
||||||
|
acc,
|
||||||
|
item,
|
||||||
|
!!acc._syntheticFromItem || accounts.length === 1
|
||||||
|
);
|
||||||
var body =
|
var body =
|
||||||
fieldRow('标签名', acc.label, showEmpty) +
|
fieldRow('标签名', acc.label, showEmpty) +
|
||||||
fieldRow('用户名', acc.username, showEmpty) +
|
fieldRow('用户名', acc.username, showEmpty) +
|
||||||
@@ -1164,7 +1305,8 @@
|
|||||||
secretRow('密码', 'password', acc.password, showEmpty, {
|
secretRow('密码', 'password', acc.password, showEmpty, {
|
||||||
accountId: acc.id || '',
|
accountId: acc.id || '',
|
||||||
}) +
|
}) +
|
||||||
fieldRow('密码提示', acc.passwordHint, showEmpty);
|
fieldRow('密码提示', acc.passwordHint, showEmpty) +
|
||||||
|
renderAccountSessionRows(sess, showEmpty);
|
||||||
var secList = acc.secrets || [];
|
var secList = acc.secrets || [];
|
||||||
var nestedSecretsHtml = secList
|
var nestedSecretsHtml = secList
|
||||||
.map(function (sec, sidx) {
|
.map(function (sec, sidx) {
|
||||||
@@ -1220,6 +1362,9 @@
|
|||||||
(secList.length
|
(secList.length
|
||||||
? '<span class="mnote-vault-muted">密钥 ' + secList.length + '</span>'
|
? '<span class="mnote-vault-muted">密钥 ' + secList.length + '</span>'
|
||||||
: '') +
|
: '') +
|
||||||
|
(sess.hasLoginSession
|
||||||
|
? '<span class="mnote-vault-session-badge is-saved is-compact" title="已保存登录态">登录态</span>'
|
||||||
|
: '') +
|
||||||
'</summary>' +
|
'</summary>' +
|
||||||
'<div class="mnote-vault-slot-body">' +
|
'<div class="mnote-vault-slot-body">' +
|
||||||
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() {
|
function collectFolderPaths() {
|
||||||
var set = {};
|
var set = {};
|
||||||
(state.items || []).forEach(function (item) {
|
(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) {
|
function formFolderPathField(value) {
|
||||||
var current = (value || '').trim();
|
var current = normalizeFolderPath(value || '');
|
||||||
var paths = collectFolderPaths();
|
var paths = collectFolderPaths();
|
||||||
var seen = {};
|
var seen = {};
|
||||||
paths.forEach(function (p) {
|
paths.forEach(function (p) {
|
||||||
seen[p] = true;
|
seen[p] = true;
|
||||||
});
|
});
|
||||||
var options =
|
// 当前值若不在列表中,仍挂到树上便于高亮/再选。
|
||||||
'<option value=""' +
|
var treePaths = paths.slice();
|
||||||
(!current ? ' selected' : '') +
|
|
||||||
'>(无分组)</option>';
|
|
||||||
paths.forEach(function (p) {
|
|
||||||
options +=
|
|
||||||
'<option value="' +
|
|
||||||
escapeHtml(p) +
|
|
||||||
'"' +
|
|
||||||
(p === current ? ' selected' : '') +
|
|
||||||
'>' +
|
|
||||||
escapeHtml(p) +
|
|
||||||
'</option>';
|
|
||||||
});
|
|
||||||
if (current && !seen[current]) {
|
if (current && !seen[current]) {
|
||||||
options +=
|
treePaths.push(current);
|
||||||
'<option value="' +
|
var parts = current.split('/').filter(Boolean);
|
||||||
escapeHtml(current) +
|
for (var i = 1; i < parts.length; i++) {
|
||||||
'" selected>' +
|
var prefix = parts.slice(0, i).join('/');
|
||||||
escapeHtml(current) +
|
if (treePaths.indexOf(prefix) < 0) treePaths.push(prefix);
|
||||||
'</option>';
|
}
|
||||||
|
}
|
||||||
|
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 (
|
return (
|
||||||
'<div class="mnote-vault-field-row mnote-vault-folder-field">' +
|
'<div class="mnote-vault-field-row mnote-vault-folder-field">' +
|
||||||
'<label for="vault-f-folderPath">分组</label>' +
|
'<label for="vault-f-folderPath">分组</label>' +
|
||||||
'<div class="mnote-vault-folder-controls">' +
|
'<div class="mnote-vault-folder-controls">' +
|
||||||
'<select id="vault-f-folderSelect" data-vault-folder-select data-testid="vault-folder-select" aria-label="选择分组">' +
|
'<div class="mnote-vault-folder-picker" data-vault-folder-picker data-testid="vault-folder-select" role="listbox" aria-label="选择分组">' +
|
||||||
options +
|
treeHtml +
|
||||||
'</select>' +
|
'</div>' +
|
||||||
'<input id="vault-f-folderPath" name="folderPath" type="text" ' +
|
'<input id="vault-f-folderPath" name="folderPath" type="text" ' +
|
||||||
'value="' +
|
'value="' +
|
||||||
escapeHtml(current) +
|
escapeHtml(current) +
|
||||||
'" placeholder="新分组可直接输入,用 / 分层" ' +
|
'" placeholder="点上方分组选择,或直接输入;用 / 分层" ' +
|
||||||
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" />' +
|
'data-vault-folder-input data-testid="vault-folder-path" list="vault-folder-path-list" autocomplete="off" />' +
|
||||||
'<datalist id="vault-folder-path-list">' +
|
'<datalist id="vault-folder-path-list">' +
|
||||||
paths
|
paths
|
||||||
.map(function (p) {
|
.map(function (p) {
|
||||||
@@ -1394,39 +1644,75 @@
|
|||||||
})
|
})
|
||||||
.join('') +
|
.join('') +
|
||||||
'</datalist>' +
|
'</datalist>' +
|
||||||
|
'<p class="mnote-vault-form-hint mnote-vault-folder-hint">分组默认折叠;点 ▶ 展开,点名称即可选中。也可在下方直接输入路径。</p>' +
|
||||||
'</div></div>'
|
'</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) {
|
function bindFolderPathControls(form) {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
var select = qs('[data-vault-folder-select]', form);
|
|
||||||
var input = qs('[data-vault-folder-input]', form);
|
var input = qs('[data-vault-folder-input]', form);
|
||||||
if (select && input) {
|
var picker = qs('[data-vault-folder-picker]', form);
|
||||||
select.addEventListener('change', function () {
|
if (picker) {
|
||||||
input.value = select.value || '';
|
picker.addEventListener('click', function (ev) {
|
||||||
state.dirty = true;
|
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) {
|
if (input) {
|
||||||
input.addEventListener('input', function () {
|
input.addEventListener('input', function () {
|
||||||
var n = normalizeFolderPath(input.value);
|
syncFolderPickerSelection(form, 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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
input.addEventListener('change', function () {
|
input.addEventListener('change', function () {
|
||||||
var n = normalizeFolderPath(input.value);
|
var n = normalizeFolderPath(input.value);
|
||||||
input.value = n;
|
input.value = n;
|
||||||
|
syncFolderPickerSelection(form, n);
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1439,24 +1725,55 @@
|
|||||||
var urls = normalizeUrlsFromItem(item);
|
var urls = normalizeUrlsFromItem(item);
|
||||||
if (!urls.length) urls = [''];
|
if (!urls.length) urls = [''];
|
||||||
state._formUrls = urls.slice();
|
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) {
|
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 {
|
return {
|
||||||
id: a.id || newClientId('acc'),
|
id: a.id || newClientId('acc'),
|
||||||
label: a.label || '',
|
label: a.label || '',
|
||||||
username: a.username || '',
|
username: a.username || '',
|
||||||
email: a.email || '',
|
email: a.email || '',
|
||||||
passwordHint: a.passwordHint || '',
|
passwordHint: a.passwordHint || '',
|
||||||
passwordState: a.password || { state: 'absent' },
|
passwordState: passwordState,
|
||||||
passwordClear: false,
|
passwordClear: passwordClear,
|
||||||
passwordValue: '',
|
passwordValue: passwordValue,
|
||||||
secrets: (a.secrets || []).map(function (s) {
|
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 {
|
return {
|
||||||
id: s.id || newClientId('sec'),
|
id: s.id || newClientId('sec'),
|
||||||
kind: s.kind || 'apikey',
|
kind: s.kind || 'apikey',
|
||||||
label: s.label || '',
|
label: s.label || '',
|
||||||
valueState: s.value || { state: 'absent' },
|
valueState: valueState,
|
||||||
valueClear: false,
|
valueClear: valueClear,
|
||||||
valueText: '',
|
valueText: valueText,
|
||||||
accountId: a.id || '',
|
accountId: a.id || '',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -1592,9 +1909,11 @@
|
|||||||
'<div class="mnote-vault-secret-edit">' +
|
'<div class="mnote-vault-secret-edit">' +
|
||||||
'<input type="text" autocomplete="off" spellcheck="false" data-acc-field="password" data-acc-id="' +
|
'<input type="text" autocomplete="off" spellcheck="false" data-acc-field="password" data-acc-id="' +
|
||||||
escapeHtml(acc.id) +
|
escapeHtml(acc.id) +
|
||||||
'" class="mnote-vault-secret-input-plain" placeholder="' +
|
'" class="mnote-vault-secret-input-plain" value="' +
|
||||||
|
escapeHtml(acc.passwordValue || '') +
|
||||||
|
'" placeholder="' +
|
||||||
escapeHtml(
|
escapeHtml(
|
||||||
!isCreate && hasPwd
|
!isCreate && hasPwd && !acc.passwordValue
|
||||||
? '留空不改;输入新值覆盖'
|
? '留空不改;输入新值覆盖'
|
||||||
: '可选;可用 [Key] 密文片段'
|
: '可选;可用 [Key] 密文片段'
|
||||||
) +
|
) +
|
||||||
@@ -1602,7 +1921,11 @@
|
|||||||
(!isCreate && hasPwd
|
(!isCreate && hasPwd
|
||||||
? '<button type="button" data-acc-show-password="' +
|
? '<button type="button" data-acc-show-password="' +
|
||||||
escapeHtml(acc.id) +
|
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="' +
|
'<button type="button" data-acc-clear-password="' +
|
||||||
escapeHtml(acc.id) +
|
escapeHtml(acc.id) +
|
||||||
'">清空</button>'
|
'">清空</button>'
|
||||||
@@ -1624,6 +1947,7 @@
|
|||||||
sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key';
|
sec.kind === 'token' ? 'Token' : sec.kind === 'other' ? '其他' : 'API Key';
|
||||||
var title = sec.label || kindLabel + ' ' + (idx + 1);
|
var title = sec.label || kindLabel + ' ' + (idx + 1);
|
||||||
var hasVal = sec.valueState && sec.valueState.state === 'masked';
|
var hasVal = sec.valueState && sec.valueState.state === 'masked';
|
||||||
|
var draftText = sec.valueText || '';
|
||||||
return (
|
return (
|
||||||
'<div class="mnote-vault-nested-secret" data-vault-secret-slot="' +
|
'<div class="mnote-vault-nested-secret" data-vault-secret-slot="' +
|
||||||
escapeHtml(sec.id) +
|
escapeHtml(sec.id) +
|
||||||
@@ -1672,15 +1996,21 @@
|
|||||||
escapeHtml(sec.id) +
|
escapeHtml(sec.id) +
|
||||||
'" data-acc-id="' +
|
'" data-acc-id="' +
|
||||||
escapeHtml(accountId) +
|
escapeHtml(accountId) +
|
||||||
'" value="" placeholder="' +
|
'" value="' +
|
||||||
escapeHtml(hasVal ? '留空不改;输入新值覆盖' : '密钥明文') +
|
escapeHtml(draftText) +
|
||||||
|
'" placeholder="' +
|
||||||
|
escapeHtml(hasVal && !draftText ? '留空不改;输入新值覆盖' : '密钥明文') +
|
||||||
'" />' +
|
'" />' +
|
||||||
(hasVal
|
(hasVal
|
||||||
? '<button type="button" data-sec-show-value="' +
|
? '<button type="button" data-sec-show-value="' +
|
||||||
escapeHtml(sec.id) +
|
escapeHtml(sec.id) +
|
||||||
'" data-acc-id="' +
|
'" data-acc-id="' +
|
||||||
escapeHtml(accountId) +
|
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="' +
|
'<button type="button" data-sec-clear-value="' +
|
||||||
escapeHtml(sec.id) +
|
escapeHtml(sec.id) +
|
||||||
'" data-acc-id="' +
|
'" data-acc-id="' +
|
||||||
@@ -1830,6 +2160,8 @@
|
|||||||
username: a.username,
|
username: a.username,
|
||||||
email: a.email,
|
email: a.email,
|
||||||
passwordHint: a.passwordHint,
|
passwordHint: a.passwordHint,
|
||||||
|
// Flags survive re-render so clear intent is not lost.
|
||||||
|
passwordClear: !!a.passwordClear,
|
||||||
password: a.passwordClear
|
password: a.passwordClear
|
||||||
? { state: 'absent' }
|
? { state: 'absent' }
|
||||||
: a.passwordValue
|
: a.passwordValue
|
||||||
@@ -1841,6 +2173,7 @@
|
|||||||
kind: s.kind,
|
kind: s.kind,
|
||||||
label: s.label,
|
label: s.label,
|
||||||
accountId: a.id,
|
accountId: a.id,
|
||||||
|
valueClear: !!s.valueClear,
|
||||||
value: s.valueClear
|
value: s.valueClear
|
||||||
? { state: 'absent' }
|
? { state: 'absent' }
|
||||||
: s.valueText
|
: s.valueText
|
||||||
@@ -2040,13 +2373,30 @@
|
|||||||
var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim());
|
var folderPath = normalizeFolderPath(String(fd.get('folderPath') || '').trim());
|
||||||
var accounts = (state._formAccounts || [])
|
var accounts = (state._formAccounts || [])
|
||||||
.map(function (acc) {
|
.map(function (acc) {
|
||||||
|
// Prefer live input draft; fall back to revealed draft left by re-render inject.
|
||||||
var raw = String(acc.passwordValue || '');
|
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 === '••••••••') {
|
if (/^[•*·]+$/.test(raw) || raw === '••••••••') {
|
||||||
toast('不能将掩码写回 password', 'error');
|
toast('不能将掩码写回 password', 'error');
|
||||||
throw new Error('mask');
|
throw new Error('mask');
|
||||||
}
|
}
|
||||||
var nestedSecrets = (acc.secrets || []).map(function (sec) {
|
var nestedSecrets = (acc.secrets || []).map(function (sec) {
|
||||||
var sraw = String(sec.valueText || '');
|
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 === '••••••••') {
|
if (/^[•*·]+$/.test(sraw) || sraw === '••••••••') {
|
||||||
toast('不能将掩码写回 secret', 'error');
|
toast('不能将掩码写回 secret', 'error');
|
||||||
throw new Error('mask');
|
throw new Error('mask');
|
||||||
|
|||||||
@@ -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:验证更改");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
¬ification_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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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(¶ms).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(¶ms).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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::acp_runtime::AcpRuntimeManager;
|
|
||||||
use crate::document_buffer_store::BufferStore;
|
use crate::document_buffer_store::BufferStore;
|
||||||
use crate::editor_actor::EditorRuntimeActor;
|
use crate::editor_actor::EditorRuntimeActor;
|
||||||
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
||||||
@@ -33,7 +32,6 @@ pub struct AppConfig {
|
|||||||
pub enable_debug_shell_routes: bool,
|
pub enable_debug_shell_routes: bool,
|
||||||
pub enable_editor_actor: bool,
|
pub enable_editor_actor: bool,
|
||||||
pub enable_page_ai_pi_lab: bool,
|
pub enable_page_ai_pi_lab: bool,
|
||||||
pub hermes_base_path: String,
|
|
||||||
pub compat_next_base_path: String,
|
pub compat_next_base_path: String,
|
||||||
pub convex_url: Option<String>,
|
pub convex_url: Option<String>,
|
||||||
pub convex_admin_key: Option<String>,
|
pub convex_admin_key: Option<String>,
|
||||||
@@ -67,8 +65,6 @@ impl AppConfig {
|
|||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
|
enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true),
|
||||||
enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true),
|
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")
|
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
|
||||||
.unwrap_or_else(|_| "/api/compat/next".into()),
|
.unwrap_or_else(|_| "/api/compat/next".into()),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
@@ -163,7 +159,6 @@ pub struct AppState {
|
|||||||
pub editor_actor: EditorRuntimeActor,
|
pub editor_actor: EditorRuntimeActor,
|
||||||
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
|
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||||
pub stream_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,
|
pub buffer_store: BufferStore,
|
||||||
control_plane: Arc<dyn ControlPlaneStore>,
|
control_plane: Arc<dyn ControlPlaneStore>,
|
||||||
}
|
}
|
||||||
@@ -185,7 +180,6 @@ impl AppState {
|
|||||||
editor_actor: actor,
|
editor_actor: actor,
|
||||||
block_delta_tx,
|
block_delta_tx,
|
||||||
stream_delta_tx,
|
stream_delta_tx,
|
||||||
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
|
|
||||||
buffer_store,
|
buffer_store,
|
||||||
control_plane,
|
control_plane,
|
||||||
}
|
}
|
||||||
@@ -389,7 +383,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: true,
|
enable_page_ai_pi_lab: true,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ mod tests {
|
|||||||
|
|
||||||
let context = RequestContext::from_http_parts(
|
let context = RequestContext::from_http_parts(
|
||||||
&Method::POST,
|
&Method::POST,
|
||||||
&"/api/hermes/bridge".parse::<Uri>().expect("uri"),
|
&"/api/mnote/tools".parse::<Uri>().expect("uri"),
|
||||||
&headers,
|
&headers,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ pub fn buffer_key_string(path: &ObjectWorkspacePath) -> String {
|
|||||||
|
|
||||||
/// 从本地文件夹写入上下文的参数构建 ObjectWorkspacePath。
|
/// 从本地文件夹写入上下文的参数构建 ObjectWorkspacePath。
|
||||||
///
|
///
|
||||||
/// 在 save_local_markdown_page、watcher event 和 Hermes 写入链中统一使用此函数构造路径。
|
/// 在 save_local_markdown_page、watcher event 和 agent tool 写入链中统一使用此函数构造路径。
|
||||||
pub fn build_local_folder_workspace_path(
|
pub fn build_local_folder_workspace_path(
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
root_uri: &str,
|
root_uri: &str,
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
#![recursion_limit = "1024"]
|
#![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 app;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod document_buffer_store;
|
pub mod document_buffer_store;
|
||||||
pub mod editor_actor;
|
pub mod editor_actor;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod evidence_parse;
|
pub mod evidence_parse;
|
||||||
pub mod hermes_tools;
|
pub mod mnote_agent_tools;
|
||||||
pub mod local_folder_watcher_registry;
|
pub mod local_folder_watcher_registry;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod page_aggregate;
|
pub mod page_aggregate;
|
||||||
@@ -29,7 +23,7 @@ pub use app::{build_app, AppConfig, AppState};
|
|||||||
pub(crate) mod test_support {
|
pub(crate) mod test_support {
|
||||||
use std::sync::{Mutex, OnceLock};
|
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();
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
LOCK.get_or_init(|| Mutex::new(()))
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
|
use crate::routes::vault_extension_token::{bearer_mnext1, verify_extension_token};
|
||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
|
|
||||||
pub async fn inject_request_context(mut request: Request, next: Next) -> 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());
|
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());
|
request.extensions_mut().insert(context.clone());
|
||||||
|
|
||||||
let mut response = next.run(request).await;
|
let mut response = next.run(request).await;
|
||||||
|
|||||||
+6
-6
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||||
use crate::routes::ensure_local_workspace_access;
|
use crate::routes::ensure_local_workspace_access;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
@@ -179,8 +179,8 @@ async fn create_artifact_node(
|
|||||||
.or_else(|| context.auth.session_id.clone()),
|
.or_else(|| context.auth.session_id.clone()),
|
||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "hermes".into(),
|
channel: "agent".into(),
|
||||||
client: "mnote-hermes-plugin".into(),
|
client: "mnote-agent-plugin".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
@@ -208,7 +208,7 @@ async fn create_artifact_node(
|
|||||||
"artifact": {
|
"artifact": {
|
||||||
"kind": node_type,
|
"kind": node_type,
|
||||||
"sourceDocumentId": document_id,
|
"sourceDocumentId": document_id,
|
||||||
"source": "hermes",
|
"source": "agent",
|
||||||
"sessionId": input.session_id,
|
"sessionId": input.session_id,
|
||||||
"runId": input.run_id,
|
"runId": input.run_id,
|
||||||
"toolCallId": input.tool_call_id,
|
"toolCallId": input.tool_call_id,
|
||||||
@@ -221,7 +221,7 @@ async fn create_artifact_node(
|
|||||||
}),
|
}),
|
||||||
preflight_data: None,
|
preflight_data: None,
|
||||||
reason: Some(tool_name.into()),
|
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,
|
dry_run: false,
|
||||||
validate_only: 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> {
|
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)
|
||||||
}
|
}
|
||||||
+16
-16
@@ -1,11 +1,11 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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,
|
aggregate_value, block_id_of, block_not_found, block_projection_blocks, find_block,
|
||||||
required_arg,
|
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 crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
apply_editor_command_to_legacy_content, RuntimeActorWire, RuntimeCommandEnvelopeWire,
|
||||||
@@ -693,7 +693,7 @@ pub(crate) fn ensure_write_contract(
|
|||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
input: &ToolCallInput,
|
input: &ToolCallInput,
|
||||||
) -> Result<(), WebError> {
|
) -> Result<(), WebError> {
|
||||||
crate::hermes_tools::ensure_write_authorized(context, input)
|
crate::mnote_agent_tools::ensure_write_authorized(context, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_leaf_block(
|
fn ensure_leaf_block(
|
||||||
@@ -1128,8 +1128,8 @@ async fn execute_page_body_save(
|
|||||||
.or_else(|| context.auth.session_id.clone()),
|
.or_else(|| context.auth.session_id.clone()),
|
||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "hermes".into(),
|
channel: "agent".into(),
|
||||||
client: "mnote-hermes-plugin".into(),
|
client: "mnote-agent-plugin".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
@@ -1142,8 +1142,8 @@ async fn execute_page_body_save(
|
|||||||
}),
|
}),
|
||||||
payload,
|
payload,
|
||||||
preflight_data: None,
|
preflight_data: None,
|
||||||
reason: Some("Hermes block tool page.body.save".into()),
|
reason: Some("agent block tool page.body.save".into()),
|
||||||
refs: vec!["page.body.save".into(), "hermes-block-tool-call".into()],
|
refs: vec!["page.body.save".into(), "agent-block-tool-call".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
validate_only: 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()),
|
.or_else(|| context.auth.session_id.clone()),
|
||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "hermes".into(),
|
channel: "agent".into(),
|
||||||
client: "mnote-hermes-plugin".into(),
|
client: "mnote-agent-plugin".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
@@ -1213,10 +1213,10 @@ pub(crate) async fn execute_page_body_save_from_aggregate(
|
|||||||
}),
|
}),
|
||||||
payload,
|
payload,
|
||||||
preflight_data: None,
|
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![
|
refs: vec![
|
||||||
"page.body.save".into(),
|
"page.body.save".into(),
|
||||||
"hermes-batch-block-tool-call".into(),
|
"agent-batch-block-tool-call".into(),
|
||||||
],
|
],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
validate_only: false,
|
validate_only: false,
|
||||||
@@ -1480,7 +1480,7 @@ mod tests {
|
|||||||
fn ensure_write_contract_rejects_read_only_ai_scope() {
|
fn ensure_write_contract_rejects_read_only_ai_scope() {
|
||||||
let context = RequestContext::from_http_parts(
|
let context = RequestContext::from_http_parts(
|
||||||
&axum::http::Method::POST,
|
&axum::http::Method::POST,
|
||||||
&"/api/hermes/tools".parse().expect("uri"),
|
&"/api/mnote/tools".parse().expect("uri"),
|
||||||
&axum::http::HeaderMap::new(),
|
&axum::http::HeaderMap::new(),
|
||||||
);
|
);
|
||||||
let input = ToolCallInput {
|
let input = ToolCallInput {
|
||||||
@@ -1521,13 +1521,13 @@ mod tests {
|
|||||||
"attrs": {},
|
"attrs": {},
|
||||||
"payload": {
|
"payload": {
|
||||||
"marks": [],
|
"marks": [],
|
||||||
"text": "Hermes 插件替换第二段",
|
"text": "agent 插件替换第二段",
|
||||||
"type": "text"
|
"type": "text"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert_eq!(content_to_text(&value), "Hermes 插件替换第二段");
|
assert_eq!(content_to_text(&value), "agent 插件替换第二段");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1540,7 +1540,7 @@ mod tests {
|
|||||||
"attrs": {},
|
"attrs": {},
|
||||||
"payload": {
|
"payload": {
|
||||||
"marks": [],
|
"marks": [],
|
||||||
"text": "Hermes 插件插入段",
|
"text": "agent 插件插入段",
|
||||||
"type": "text"
|
"type": "text"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1548,6 +1548,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert_eq!(content_to_text(&value), "Hermes 插件插入段");
|
assert_eq!(content_to_text(&value), "agent 插件插入段");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::{doc, ToolCallInput};
|
use crate::mnote_agent_tools::{doc, ToolCallInput};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub async fn context_snapshot(
|
pub async fn context_snapshot(
|
||||||
+7
-7
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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 crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -346,7 +346,7 @@ pub async fn plan_update(
|
|||||||
if !input.has_idempotency_key() {
|
if !input.has_idempotency_key() {
|
||||||
return Err(WebError::bad_request_code(
|
return Err(WebError::bad_request_code(
|
||||||
"mnote_tool_idempotency_required",
|
"mnote_tool_idempotency_required",
|
||||||
"写入计划型 mnote Hermes tool 必须携带 idempotencyKey",
|
"写入计划型 mnote agent tool 必须携带 idempotencyKey",
|
||||||
)
|
)
|
||||||
.with_context(context));
|
.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_file = document_id.starts_with('/') || document_id.starts_with("./");
|
||||||
let is_local_workspace =
|
let is_local_workspace =
|
||||||
source_kind.as_deref() == Some("local_folder") && root_uri.as_deref().is_some();
|
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 形式)
|
// 1. 读取当前文档内容(markdown 形式)
|
||||||
let (current_md, source) = if is_local_file {
|
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 {
|
match aggregate_value(state, context, input).await {
|
||||||
Ok(agg) => {
|
Ok(agg) => {
|
||||||
let blocks = block_projection_blocks(&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)
|
(agg, blocks, original_content)
|
||||||
}
|
}
|
||||||
Err(_) if use_full_content.is_some() => {
|
Err(_) if use_full_content.is_some() => {
|
||||||
@@ -1756,8 +1756,8 @@ pub async fn doc_markdown_edit(
|
|||||||
.or_else(|| context.auth.session_id.clone()),
|
.or_else(|| context.auth.session_id.clone()),
|
||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "mnote-hermes".into(),
|
channel: "mnote-agent".into(),
|
||||||
client: "mnote-hermes-plugin".into(),
|
client: "mnote-agent-plugin".into(),
|
||||||
source_kind,
|
source_kind,
|
||||||
root_uri,
|
root_uri,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
@@ -1771,7 +1771,7 @@ pub async fn doc_markdown_edit(
|
|||||||
payload,
|
payload,
|
||||||
preflight_data: None,
|
preflight_data: None,
|
||||||
reason: Some("mnote.doc.markdown_edit (7-27)".into()),
|
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,
|
dry_run: false,
|
||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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 crate::routes;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::ToolCallInput;
|
use crate::mnote_agent_tools::ToolCallInput;
|
||||||
use crate::routes::knowledge_rag::{
|
use crate::routes::knowledge_rag::{
|
||||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSearchRequest,
|
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSearchRequest,
|
||||||
KnowledgeRagSectionContextRequest, KnowledgeRagStatusQuery,
|
KnowledgeRagSectionContextRequest, KnowledgeRagStatusQuery,
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
use super::skill;
|
use super::skill;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.hermes_tool_manifest.v1";
|
pub const MANIFEST_SCHEMA_VERSION: &str = "mnote.agent_tool_manifest.v1";
|
||||||
pub const TOOL_SCHEMA_VERSION: &str = "mnote.hermes_tool.v1";
|
pub const TOOL_SCHEMA_VERSION: &str = "mnote.agent_tool.v1";
|
||||||
|
|
||||||
pub fn manifest() -> Value {
|
pub fn manifest() -> Value {
|
||||||
let tools = annotate_tools_with_capabilities(assemble_all_tools());
|
let tools = annotate_tools_with_capabilities(assemble_all_tools());
|
||||||
+5
-5
@@ -177,7 +177,7 @@ impl ToolCallInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 hermes_tools 写入守卫。
|
/// CommandContext 桥接信息,用于将 `core-protocol` 的 command context 引入 agent tools 写入守卫。
|
||||||
///
|
///
|
||||||
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
|
/// 当此桥接可用时,`ensure_write_authorized` 除检查 `ToolCallInput` 自带的
|
||||||
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
|
/// `aiAccessScope.permissionLevel` 外,额外检查 `ai_can_write` 和 `workspace_readonly`。
|
||||||
@@ -191,7 +191,7 @@ pub struct CommandContextBridge {
|
|||||||
pub ai_can_write: bool,
|
pub ai_can_write: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统一的 hermes_tools 写入守卫。检查:
|
/// 统一的 agent tools 写入守卫。检查:
|
||||||
///
|
///
|
||||||
/// - `idempotencyKey` 必须存在
|
/// - `idempotencyKey` 必须存在
|
||||||
/// - `dryRun` 必须显式携带
|
/// - `dryRun` 必须显式携带
|
||||||
@@ -207,14 +207,14 @@ pub fn ensure_write_authorized(
|
|||||||
if !input.has_idempotency_key() {
|
if !input.has_idempotency_key() {
|
||||||
return Err(WebError::bad_request_code(
|
return Err(WebError::bad_request_code(
|
||||||
"mnote_tool_idempotency_required",
|
"mnote_tool_idempotency_required",
|
||||||
"写入型 mnote Hermes tool 必须携带 idempotencyKey",
|
"写入型 mnote agent tool 必须携带 idempotencyKey",
|
||||||
)
|
)
|
||||||
.with_context(context));
|
.with_context(context));
|
||||||
}
|
}
|
||||||
if input.dry_run.is_none() {
|
if input.dry_run.is_none() {
|
||||||
return Err(WebError::bad_request_code(
|
return Err(WebError::bad_request_code(
|
||||||
"mnote_tool_dry_run_required",
|
"mnote_tool_dry_run_required",
|
||||||
"写入型 mnote Hermes tool 必须显式携带 dryRun",
|
"写入型 mnote agent tool 必须显式携带 dryRun",
|
||||||
)
|
)
|
||||||
.with_context(context));
|
.with_context(context));
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ mod tests {
|
|||||||
fn context() -> RequestContext {
|
fn context() -> RequestContext {
|
||||||
RequestContext::from_http_parts(
|
RequestContext::from_http_parts(
|
||||||
&Method::POST,
|
&Method::POST,
|
||||||
&"/api/hermes/tools".parse().expect("uri"),
|
&"/api/mnote/tools".parse().expect("uri"),
|
||||||
&HeaderMap::new(),
|
&HeaderMap::new(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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 crate::routes::onlyoffice_bridge::{self, BridgeResultWire, BridgeRunError};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
+7
-7
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts;
|
||||||
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
use crate::routes::web_shell::build_page_aggregate_snapshot;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::{
|
||||||
@@ -18,7 +18,7 @@ pub async fn page_get(
|
|||||||
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
|
WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId")
|
||||||
.with_context(context)
|
.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 workspace_id = input.effective_workspace_id();
|
||||||
let source_kind = input.effective_source_kind();
|
let source_kind = input.effective_source_kind();
|
||||||
let root_uri = input.effective_root_uri();
|
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> {
|
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(
|
pub async fn page_save(
|
||||||
@@ -287,8 +287,8 @@ async fn page_command(
|
|||||||
.or_else(|| context.auth.session_id.clone()),
|
.or_else(|| context.auth.session_id.clone()),
|
||||||
},
|
},
|
||||||
source: RuntimeSourceWire {
|
source: RuntimeSourceWire {
|
||||||
channel: "hermes".into(),
|
channel: "agent".into(),
|
||||||
client: "mnote-hermes-plugin".into(),
|
client: "mnote-agent-plugin".into(),
|
||||||
source_kind: None,
|
source_kind: None,
|
||||||
root_uri: None,
|
root_uri: None,
|
||||||
workspace_id: workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
@@ -301,8 +301,8 @@ async fn page_command(
|
|||||||
}),
|
}),
|
||||||
payload,
|
payload,
|
||||||
preflight_data: None,
|
preflight_data: None,
|
||||||
reason: Some(format!("Hermes tool {command_name}")),
|
reason: Some(format!("agent tool {command_name}")),
|
||||||
refs: vec![command_name.into(), "hermes-tool-call".into()],
|
refs: vec![command_name.into(), "agent-tool-call".into()],
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
validate_only: false,
|
validate_only: false,
|
||||||
};
|
};
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::ToolCallInput;
|
use crate::mnote_agent_tools::ToolCallInput;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -468,7 +468,7 @@ fn ensure_resource_write_contract(
|
|||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
input: &ToolCallInput,
|
input: &ToolCallInput,
|
||||||
) -> Result<(), WebError> {
|
) -> 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> {
|
fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
|
||||||
+29
-33
@@ -1,6 +1,6 @@
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::ToolCallInput;
|
use crate::mnote_agent_tools::ToolCallInput;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
|||||||
title: "当前页读取",
|
title: "当前页读取",
|
||||||
description: "仅在任务需要当前 MNote Markdown 页面内容时读取当前页。",
|
description: "仅在任务需要当前 MNote Markdown 页面内容时读取当前页。",
|
||||||
category: "mnote",
|
category: "mnote",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["pi"],
|
||||||
read_only: true,
|
read_only: true,
|
||||||
requires_context_refs: &["current_page"],
|
requires_context_refs: &["current_page"],
|
||||||
tool_names: &[
|
tool_names: &[
|
||||||
@@ -40,7 +40,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
|||||||
title: "知识库问答",
|
title: "知识库问答",
|
||||||
description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG。",
|
description: "通过 LightRAG 知识库检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 LightRAG。",
|
||||||
category: "knowledge",
|
category: "knowledge",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["pi"],
|
||||||
read_only: true,
|
read_only: true,
|
||||||
requires_context_refs: &["folder"],
|
requires_context_refs: &["folder"],
|
||||||
tool_names: &[
|
tool_names: &[
|
||||||
@@ -58,7 +58,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
|||||||
title: "本地文件编辑",
|
title: "本地文件编辑",
|
||||||
description: "在 MNote 授权目录内读取和修改本地 Markdown 文件。",
|
description: "在 MNote 授权目录内读取和修改本地 Markdown 文件。",
|
||||||
category: "file",
|
category: "file",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["pi"],
|
||||||
read_only: false,
|
read_only: false,
|
||||||
requires_context_refs: &["current_page", "file", "folder"],
|
requires_context_refs: &["current_page", "file", "folder"],
|
||||||
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
||||||
@@ -69,7 +69,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
|||||||
title: "ONLYOFFICE 实时编辑",
|
title: "ONLYOFFICE 实时编辑",
|
||||||
description: "操作当前已打开的 ONLYOFFICE Word、Excel、PPT 编辑会话。",
|
description: "操作当前已打开的 ONLYOFFICE Word、Excel、PPT 编辑会话。",
|
||||||
category: "office",
|
category: "office",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["pi"],
|
||||||
read_only: false,
|
read_only: false,
|
||||||
requires_context_refs: &["onlyoffice"],
|
requires_context_refs: &["onlyoffice"],
|
||||||
tool_names: &[
|
tool_names: &[
|
||||||
@@ -116,7 +116,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
|||||||
title: "思维导图",
|
title: "思维导图",
|
||||||
description: "读取、更新、总结或创建 MNote 思维导图资源。",
|
description: "读取、更新、总结或创建 MNote 思维导图资源。",
|
||||||
category: "resource",
|
category: "resource",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["pi"],
|
||||||
read_only: false,
|
read_only: false,
|
||||||
requires_context_refs: &["current_page", "file", "folder", "resource"],
|
requires_context_refs: &["current_page", "file", "folder", "resource"],
|
||||||
tool_names: &[
|
tool_names: &[
|
||||||
@@ -133,7 +133,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
|||||||
title: "密码箱 / AI 密码本",
|
title: "密码箱 / AI 密码本",
|
||||||
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*(token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
|
description: "密码箱与 AI 密码本:读密/login/session 用 mnote-vault CLI 或 Pi mnote.vault.*(token+core/UDS,不依赖 3000);禁止通用文件工具读 .mnote/vault。",
|
||||||
category: "security",
|
category: "security",
|
||||||
agent_ids: &["hermes", "reasonix"],
|
agent_ids: &["pi"],
|
||||||
read_only: true,
|
read_only: true,
|
||||||
requires_context_refs: &["folder"],
|
requires_context_refs: &["folder"],
|
||||||
tool_names: &[
|
tool_names: &[
|
||||||
@@ -153,7 +153,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
|||||||
title: "纯聊天",
|
title: "纯聊天",
|
||||||
description: "只进行对话回复,不读取或写入 MNote 页面、文件上下文。",
|
description: "只进行对话回复,不读取或写入 MNote 页面、文件上下文。",
|
||||||
category: "chat",
|
category: "chat",
|
||||||
agent_ids: &["chat_only", "hermes", "reasonix"],
|
agent_ids: &["chat_only", "pi"],
|
||||||
read_only: true,
|
read_only: true,
|
||||||
requires_context_refs: &[],
|
requires_context_refs: &[],
|
||||||
tool_names: &[],
|
tool_names: &[],
|
||||||
@@ -306,18 +306,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_lookup_rejects_unknown_or_agent_mismatch() {
|
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("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]
|
#[test]
|
||||||
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
|
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
|
||||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
let pi_agent_skills = skill_summaries_for_agent(Some("pi"));
|
||||||
let skill = reasonix_skills
|
let skill = pi_agent_skills
|
||||||
.iter()
|
.iter()
|
||||||
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
|
.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["readOnly"], false);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
skill["toolNames"]
|
skill["toolNames"]
|
||||||
@@ -363,11 +363,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_registry_exposes_mindmap_skill_to_agents() {
|
fn skill_registry_exposes_mindmap_skill_to_agents() {
|
||||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
let pi_agent_skills = skill_summaries_for_agent(Some("pi"));
|
||||||
let skill = reasonix_skills
|
let skill = pi_agent_skills
|
||||||
.iter()
|
.iter()
|
||||||
.find(|skill| skill["id"] == "mnote-mindmap")
|
.find(|skill| skill["id"] == "mnote-mindmap")
|
||||||
.expect("reasonix should see mindmap skill");
|
.expect("pi should see mindmap skill");
|
||||||
assert_eq!(skill["readOnly"], false);
|
assert_eq!(skill["readOnly"], false);
|
||||||
assert!(skill["requiresContextRefs"]
|
assert!(skill["requiresContextRefs"]
|
||||||
.as_array()
|
.as_array()
|
||||||
@@ -383,12 +383,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_registry_exposes_vault_skill_to_agents() {
|
fn skill_registry_exposes_vault_skill_to_agents() {
|
||||||
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
|
let pi_skills = skill_summaries_for_agent(Some("pi"));
|
||||||
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
let skill = pi_skills
|
||||||
let skill = reasonix_skills
|
|
||||||
.iter()
|
.iter()
|
||||||
.find(|skill| skill["id"] == "mnote-vault")
|
.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["readOnly"], true);
|
||||||
assert_eq!(skill["category"], "security");
|
assert_eq!(skill["category"], "security");
|
||||||
assert!(skill["toolNames"]
|
assert!(skill["toolNames"]
|
||||||
@@ -396,12 +395,9 @@ mod tests {
|
|||||||
.expect("tool names")
|
.expect("tool names")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|name| name == "mnote.vault.resolve"));
|
.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());
|
assert!(find_skill("mnote-vault", Some("chat_only")).is_none());
|
||||||
let body = find_skill("mnote-vault", Some("hermes"))
|
let body = find_skill("mnote-vault", Some("pi"))
|
||||||
.expect("hermes can read vault skill")
|
.expect("pi can read vault skill")
|
||||||
.content;
|
.content;
|
||||||
assert!(body.contains(".mnote/vault"));
|
assert!(body.contains(".mnote/vault"));
|
||||||
assert!(body.contains("共享到 AI") || body.contains("AI 密码本"));
|
assert!(body.contains("共享到 AI") || body.contains("AI 密码本"));
|
||||||
@@ -409,28 +405,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
|
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
|
||||||
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
|
let pi_skills = skill_summaries_for_agent(Some("pi"));
|
||||||
assert!(!hermes_skills
|
assert!(!pi_skills
|
||||||
.iter()
|
.iter()
|
||||||
.any(|skill| skill["id"] == "mnote-local-index"));
|
.any(|skill| skill["id"] == "mnote-local-index"));
|
||||||
let skill = hermes_skills
|
let skill = pi_skills
|
||||||
.iter()
|
.iter()
|
||||||
.find(|skill| skill["id"] == "mnote-knowledge-rag")
|
.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_eq!(skill["readOnly"], true);
|
||||||
assert!(skill["toolNames"]
|
assert!(skill["toolNames"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("tool names")
|
.expect("tool names")
|
||||||
.iter()
|
.iter()
|
||||||
.any(|name| name == "mnote.knowledge_rag.query"));
|
.any(|name| name == "mnote.knowledge_rag.query"));
|
||||||
assert!(!hermes_skills
|
assert!(!pi_skills
|
||||||
.iter()
|
.iter()
|
||||||
.any(|skill| skill["id"] == "mnote-document-evidence"));
|
.any(|skill| skill["id"] == "mnote-document-evidence"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_read_maps_document_evidence_alias_to_lightrag() {
|
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");
|
.expect("compat alias should resolve");
|
||||||
assert_eq!(skill.id, "mnote-knowledge-rag");
|
assert_eq!(skill.id, "mnote-knowledge-rag");
|
||||||
}
|
}
|
||||||
@@ -439,14 +435,14 @@ mod tests {
|
|||||||
async fn skill_read_returns_mindmap_skill_content() {
|
async fn skill_read_returns_mindmap_skill_content() {
|
||||||
let context = RequestContext::from_http_parts(
|
let context = RequestContext::from_http_parts(
|
||||||
&axum::http::Method::POST,
|
&axum::http::Method::POST,
|
||||||
&"/api/hermes/tools/execute".parse().expect("uri"),
|
&"/api/mnote/tools/call".parse().expect("uri"),
|
||||||
&axum::http::HeaderMap::new(),
|
&axum::http::HeaderMap::new(),
|
||||||
);
|
);
|
||||||
let input: ToolCallInput = serde_json::from_value(json!({
|
let input: ToolCallInput = serde_json::from_value(json!({
|
||||||
"toolName": "mnote.skill.read",
|
"toolName": "mnote.skill.read",
|
||||||
"args": {
|
"args": {
|
||||||
"skillId": "mnote-mindmap",
|
"skillId": "mnote-mindmap",
|
||||||
"agentId": "reasonix"
|
"agentId": "pi"
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.expect("input");
|
.expect("input");
|
||||||
@@ -1385,7 +1385,7 @@ fn default_skill_registry() -> HashMap<String, SkillConfig> {
|
|||||||
skill_config(
|
skill_config(
|
||||||
"Global Search",
|
"Global Search",
|
||||||
"聚合本机/网页搜索线索,适合研究型查询入口。",
|
"聚合本机/网页搜索线索,适合研究型查询入口。",
|
||||||
"/home/lix/.hermes/profiles/lite/skills/global-search/SKILL.md",
|
"/home/lix/.mnote/agent-profiles/lite/skills/global-search/SKILL.md",
|
||||||
"medium",
|
"medium",
|
||||||
&["network:search"],
|
&["network:search"],
|
||||||
),
|
),
|
||||||
@@ -3611,7 +3611,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn admin_user_display_role_includes_access_policy_admins() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
let policy_root = std::env::temp_dir().join(format!(
|
let policy_root = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub async fn next_ai_agent_run(
|
|||||||
StatusCode::GONE,
|
StatusCode::GONE,
|
||||||
"legacy_ai_agent_run_retired",
|
"legacy_ai_agent_run_retired",
|
||||||
format!(
|
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)
|
.with_context(&context)
|
||||||
@@ -74,7 +74,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -114,7 +113,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -156,7 +154,7 @@ mod tests {
|
|||||||
.expect("body");
|
.expect("body");
|
||||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||||
assert!(text.contains("legacy_ai_agent_run_retired"));
|
assert!(text.contains("legacy_ai_agent_run_retired"));
|
||||||
assert!(text.contains("Hermes client proxy"));
|
assert!(text.contains("Pi Lab"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -171,7 +169,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -210,7 +207,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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(
|
let next_app = axum::Router::new().route(
|
||||||
"/api/ai-agent/run",
|
"/api/ai-agent/run",
|
||||||
axum::routing::post(|| async move {
|
axum::routing::post(|| async move {
|
||||||
@@ -240,7 +237,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -523,7 +523,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: false,
|
enable_editor_actor: false,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -601,7 +600,7 @@ mod tests {
|
|||||||
"session_id": "test-session-1",
|
"session_id": "test-session-1",
|
||||||
"run_id": "test-run-1",
|
"run_id": "test-run-1",
|
||||||
"profile": "test-profile",
|
"profile": "test-profile",
|
||||||
"acp_runtime": "reasonix",
|
"acp_runtime": "pi",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"events": [{
|
"events": [{
|
||||||
"eventType": "message",
|
"eventType": "message",
|
||||||
@@ -616,7 +615,7 @@ mod tests {
|
|||||||
assert_eq!(payload["results"][0]["kind"], "seedAiRuntime");
|
assert_eq!(payload["results"][0]["kind"], "seedAiRuntime");
|
||||||
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
|
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"]["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]["run"]["status"], "running");
|
||||||
assert_eq!(payload["results"][0]["events"].as_array().unwrap().len(), 1);
|
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!(payload["results"][0]["run"].is_object());
|
||||||
assert_eq!(payload["results"][0]["run"]["run_id"], "test-run-1");
|
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"]["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]["run"]["status"], "running");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1197,7 +1197,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -1780,7 +1780,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: true,
|
enable_debug_shell_routes: true,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -389,7 +389,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -2998,7 +2998,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url,
|
convex_url,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -3418,7 +3417,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn root_entry_renders_local_first_landing_without_convex() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
let base = temp_root("mnote-root-local-first-landing");
|
let base = temp_root("mnote-root-local-first-landing");
|
||||||
@@ -3521,7 +3520,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -3589,7 +3587,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -3897,7 +3894,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -4039,11 +4035,13 @@ mod tests {
|
|||||||
&root_uri,
|
&root_uri,
|
||||||
)
|
)
|
||||||
.expect("init local workspace");
|
.expect("init local workspace");
|
||||||
|
// pageId 触发 reveal:lazy 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)
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.uri(format!(
|
.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-id", "user_real")
|
||||||
.header("x-mnote-actor-type", "user")
|
.header("x-mnote-actor-type", "user")
|
||||||
@@ -4181,7 +4179,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn root_entry_initializes_default_local_workspace_page() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
let base = temp_root("mnote-root-default-local-workspace");
|
let base = temp_root("mnote-root-default-local-workspace");
|
||||||
@@ -4239,7 +4237,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bare_vault_entry_returns_friendly_200_without_convex_bootstrap() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||||
|
|||||||
@@ -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_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -505,7 +505,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: 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) {
|
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() {
|
if let Ok(mut cache) = local_folder_metadata_cache().lock() {
|
||||||
cache.remove(&key);
|
cache.remove(&key);
|
||||||
|
if raw_key != key {
|
||||||
|
cache.remove(&raw_key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Ok(mut cache) = local_folder_watch_revision_cache().lock() {
|
if let Ok(mut cache) = local_folder_watch_revision_cache().lock() {
|
||||||
cache.remove(&key);
|
cache.remove(&key);
|
||||||
|
if raw_key != key {
|
||||||
|
cache.remove(&raw_key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10703,7 +10715,7 @@ mod tests {
|
|||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
fn env_lock() -> &'static 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 {
|
fn temp_root(name: &str) -> std::path::PathBuf {
|
||||||
@@ -10735,7 +10747,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -10926,6 +10937,7 @@ mod tests {
|
|||||||
.expect("write md");
|
.expect("write md");
|
||||||
let root_uri = format!("file://{}", root.display());
|
let root_uri = format!("file://{}", root.display());
|
||||||
|
|
||||||
|
// 根快照 lazy:仅一层;根级 md 直接可见。
|
||||||
let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot");
|
let first = load_local_folder_page_tree_snapshot(&root_uri).expect("first snapshot");
|
||||||
let first_json = first.projection.to_string();
|
let first_json = first.projection.to_string();
|
||||||
assert!(first_json.contains("local-md:page.md"));
|
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::create_dir_all(root.join("docs")).expect("create docs");
|
||||||
std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md"))
|
std::fs::rename(root.join("page.md"), root.join("docs").join("renamed.md"))
|
||||||
.expect("move 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();
|
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("local-mdid:stable-frontmatter-id"));
|
||||||
assert!(second_json.contains("renamed.md"));
|
assert!(second_json.contains("renamed.md"));
|
||||||
|
|
||||||
@@ -10957,9 +10974,14 @@ mod tests {
|
|||||||
.expect("write page ids");
|
.expect("write page ids");
|
||||||
let root_uri = format!("file://{}", root.display());
|
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();
|
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("local-mdid:stable-from-page-ids"));
|
||||||
assert!(!html_json.contains("page-ids.json"));
|
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"]
|
let page_items = page_tree.projection["items"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.expect("page items");
|
.expect("page items");
|
||||||
assert!(page_items
|
assert!(
|
||||||
.iter()
|
page_items
|
||||||
.any(|item| item["documentId"].as_str() == Some("local-md:docs~2FPage.md")));
|
.iter()
|
||||||
|
.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()
|
assert!(!page_items.iter().any(|item| item["documentId"].as_str()
|
||||||
== Some("local-md:docs~2FPage.ocr~2Fphoto.png.ocr.md")));
|
== 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");
|
let first = local_folder_watch_revision(&root_uri).expect("first revision");
|
||||||
// Mutate tree immediately; TTL should still serve previous 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");
|
let cached = local_folder_watch_revision(&root_uri).expect("cached revision");
|
||||||
assert_eq!(first.revision, cached.revision);
|
assert_eq!(first.revision, cached.revision);
|
||||||
|
|
||||||
// Explicit invalidation path (same as metadata writes) must force recompute.
|
// Explicit invalidation path (same as metadata writes) must force recompute.
|
||||||
invalidate_local_folder_metadata_cache(&root);
|
invalidate_local_folder_metadata_cache(&root);
|
||||||
let after_invalidate = local_folder_watch_revision(&root_uri).expect("fresh revision");
|
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);
|
set_local_folder_watch_revision_cache_ttl_ms_for_test(2_000);
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
|||||||
@@ -5170,7 +5170,7 @@ mod tests {
|
|||||||
let workspace_id = "local-ws-evidence-sqlite";
|
let workspace_id = "local-ws-evidence-sqlite";
|
||||||
fs::write(
|
fs::write(
|
||||||
root.join("README.md"),
|
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");
|
.expect("write home");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -5239,7 +5239,7 @@ mod tests {
|
|||||||
assert!(section_count >= 3);
|
assert!(section_count >= 3);
|
||||||
let mention_source_block: String = connection
|
let mention_source_block: String = connection
|
||||||
.query_row(
|
.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),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
@@ -5253,7 +5253,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("mention source locator");
|
.expect("mention source locator");
|
||||||
assert!(mention_locator.contains("mnote.evidence_locator.v1"));
|
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("graph query")
|
||||||
.expect("sqlite exists");
|
.expect("sqlite exists");
|
||||||
let mention_result = graph_results
|
let mention_result = graph_results
|
||||||
|
|||||||
@@ -487,7 +487,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -353,7 +353,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -422,7 +421,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -508,7 +506,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mindmap_standalone_bootstrap_propagates_dev_hot_to_island_runtime() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
|
|||||||
+431
-211
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,7 @@ mod editor;
|
|||||||
pub(crate) mod evidence;
|
pub(crate) mod evidence;
|
||||||
mod gateway;
|
mod gateway;
|
||||||
mod health;
|
mod health;
|
||||||
mod hermes;
|
mod mnote_tools;
|
||||||
mod hermes_client;
|
|
||||||
mod hermes_tools;
|
|
||||||
mod kernel;
|
mod kernel;
|
||||||
pub(crate) mod knowledge_rag;
|
pub(crate) mod knowledge_rag;
|
||||||
mod local_folder_events;
|
mod local_folder_events;
|
||||||
@@ -26,8 +24,6 @@ mod mindmap_shell;
|
|||||||
pub(crate) mod navigation_recent;
|
pub(crate) mod navigation_recent;
|
||||||
mod onlyoffice;
|
mod onlyoffice;
|
||||||
pub(crate) mod onlyoffice_bridge;
|
pub(crate) mod onlyoffice_bridge;
|
||||||
mod page_ai_board;
|
|
||||||
mod page_ai_opencode;
|
|
||||||
mod page_ai_pi;
|
mod page_ai_pi;
|
||||||
mod page_ai_workflow;
|
mod page_ai_workflow;
|
||||||
mod query_support;
|
mod query_support;
|
||||||
@@ -43,6 +39,7 @@ mod tree_view_state;
|
|||||||
mod ui_debug;
|
mod ui_debug;
|
||||||
pub(crate) mod ui_preferences;
|
pub(crate) mod ui_preferences;
|
||||||
mod vault;
|
mod vault;
|
||||||
|
pub(crate) mod vault_extension_token;
|
||||||
mod vault_path;
|
mod vault_path;
|
||||||
mod vault_store;
|
mod vault_store;
|
||||||
mod vault_transport;
|
mod vault_transport;
|
||||||
@@ -72,7 +69,6 @@ use axum::routing::{any, delete, get, post, put};
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
pub fn build_router(state: AppState) -> 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 enable_debug_shell_routes = state.config().enable_debug_shell_routes;
|
||||||
|
|
||||||
let mut router = Router::new()
|
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",
|
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
|
||||||
get(web_shell::sidebar_attachment_open_runtime_asset),
|
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(
|
.route(
|
||||||
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
|
"/api/mnote-browser-runtime/sidebar-page-ai-runtime.js",
|
||||||
get(web_shell::sidebar_page_ai_runtime_asset),
|
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(
|
.route(
|
||||||
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
|
"/api/mnote-browser-runtime/sidebar-page-ai-pi-lab-runtime.js",
|
||||||
get(web_shell::sidebar_page_ai_pi_lab_runtime_asset),
|
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",
|
"/api/vault/items/{id}/unshare-from-ai",
|
||||||
post(vault::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/list", get(vault::list_ai))
|
||||||
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
|
.route("/api/vault/ai/items/{id}", get(vault::get_ai_item))
|
||||||
.route(
|
.route(
|
||||||
@@ -463,140 +439,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/auth/whoami", get(session::session))
|
.route("/api/auth/whoami", get(session::session))
|
||||||
.route("/api/auth/mnote-web-token", get(session::session))
|
.route("/api/auth/mnote-web-token", get(session::session))
|
||||||
.route("/api/auth/session/refresh", post(session::refresh_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/status", get(page_ai_pi::status))
|
||||||
.route("/api/page-ai/pi/bootstrap", post(page_ai_pi::bootstrap))
|
.route("/api/page-ai/pi/bootstrap", post(page_ai_pi::bootstrap))
|
||||||
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
|
.route("/api/page-ai/pi/start", post(page_ai_pi::start))
|
||||||
@@ -820,63 +662,6 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/onlyoffice/bridge/capabilities",
|
"/api/onlyoffice/bridge/capabilities",
|
||||||
get(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(
|
.route(
|
||||||
"/api/onlyoffice/bridge/commands",
|
"/api/onlyoffice/bridge/commands",
|
||||||
post(onlyoffice_bridge::enqueue_command),
|
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/tree/events", get(sse::tree_events))
|
||||||
.route("/api/stream/events", get(sse::events))
|
.route("/api/stream/events", get(sse::events))
|
||||||
.route("/api/realtime/ws", get(ws::socket))
|
.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(
|
.nest(
|
||||||
"/api/mnote/tools",
|
"/api/mnote/tools",
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/manifest", get(hermes_tools::mnote_manifest))
|
.route("/manifest", get(mnote_tools::mnote_manifest))
|
||||||
.route("/call", post(hermes_tools::mnote_call))
|
.route("/call", post(mnote_tools::mnote_call))
|
||||||
.route("/audit", get(hermes_tools::mnote_audit)),
|
.route("/audit", get(mnote_tools::mnote_audit)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if enable_debug_shell_routes {
|
if enable_debug_shell_routes {
|
||||||
@@ -1142,7 +845,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes,
|
enable_debug_shell_routes,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -1253,15 +955,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 [
|
for (method, path) in [
|
||||||
("GET", "/api/mnote/tools/manifest"),
|
("GET", "/api/mnote/tools/manifest"),
|
||||||
("POST", "/api/mnote/tools/call"),
|
("POST", "/api/mnote/tools/call"),
|
||||||
("GET", "/api/mnote/tools/audit"),
|
("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)
|
let response = app(false)
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
@@ -1275,7 +974,7 @@ mod tests {
|
|||||||
assert_ne!(
|
assert_ne!(
|
||||||
response.status(),
|
response.status(),
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
"{method} {path} 应挂到 MNote tool executor;Hermes 路径只作为 legacy alias 保留",
|
"{method} {path} 应挂到 MNote tool executor;历史 alias 已移除;仅挂 MNote tool executor",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1543,12 +1242,12 @@ mod tests {
|
|||||||
"rootUri": root_uri,
|
"rootUri": root_uri,
|
||||||
"documentId": "local-md:ai.md",
|
"documentId": "local-md:ai.md",
|
||||||
"updates": {
|
"updates": {
|
||||||
"ai.common.default_agent_id": "reasonix",
|
"ai.common.default_agent_id": "pi",
|
||||||
"ai.common.context_refs.default_selected": {
|
"ai.common.context_refs.default_selected": {
|
||||||
"current_page": true,
|
"current_page": true,
|
||||||
"folder": true
|
"folder": true
|
||||||
},
|
},
|
||||||
"ai.agent.hermes.profile_id": "mnoteai",
|
"ai.agent.pi.profile_id": "mnoteai",
|
||||||
"localOcr.autoEnabled": true
|
"localOcr.autoEnabled": true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1597,7 +1296,7 @@ mod tests {
|
|||||||
let alice_payload: Value = serde_json::from_slice(&alice_body).expect("alice json");
|
let alice_payload: Value = serde_json::from_slice(&alice_body).expect("alice json");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
alice_payload["result"]["aiPreferences"]["ai.common.default_agent_id"],
|
alice_payload["result"]["aiPreferences"]["ai.common.default_agent_id"],
|
||||||
"reasonix"
|
"pi"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]
|
alice_payload["result"]["aiPreferences"]["ai.common.context_refs.default_selected"]
|
||||||
@@ -1605,7 +1304,7 @@ mod tests {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
alice_payload["result"]["aiPreferences"]["ai.agent.hermes.profile_id"],
|
alice_payload["result"]["aiPreferences"]["ai.agent.pi.profile_id"],
|
||||||
"mnoteai"
|
"mnoteai"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1662,14 +1361,7 @@ mod tests {
|
|||||||
"/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
|
"/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js",
|
||||||
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
|
"/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js",
|
||||||
"/api/mnote-browser-runtime/sidebar-page-ai-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-pi-lab-runtime.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-settings-runtime.js",
|
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
|
||||||
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
|
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
|
||||||
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
|
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -1999,7 +1999,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: 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::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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 crate::routes::{ai_settings, knowledge_rag, local_folder_source};
|
||||||
use axum::extract::{Extension, Query, State};
|
use axum::extract::{Extension, Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
@@ -8956,7 +8956,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: true,
|
enable_page_ai_pi_lab: true,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use crate::hermes_tools::ToolCallInput;
|
use crate::mnote_agent_tools::ToolCallInput;
|
||||||
use axum::extract::{Extension, State};
|
use axum::extract::{Extension, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
@@ -132,7 +132,7 @@ pub async fn block_edit_workflow(
|
|||||||
};
|
};
|
||||||
let apply_started = Instant::now();
|
let apply_started = Instant::now();
|
||||||
let tool_response =
|
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_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
|
||||||
let apply_ms = apply_started.elapsed().as_millis();
|
let apply_ms = apply_started.elapsed().as_millis();
|
||||||
info!(
|
info!(
|
||||||
@@ -518,21 +518,35 @@ fn quoted_segments(value: &str) -> Vec<String> {
|
|||||||
segments
|
segments
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hermes_home() -> PathBuf {
|
fn agent_profile_home() -> PathBuf {
|
||||||
std::env::var("HERMES_HOME")
|
std::env::var("MNOTE_AGENT_HOME")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
std::env::var("HOME")
|
std::env::var("HERMES_HOME")
|
||||||
.ok()
|
.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 {
|
fn profile_config_path(profile: &str) -> PathBuf {
|
||||||
let home = hermes_home();
|
let home = agent_profile_home();
|
||||||
let profile = profile.trim();
|
let profile = profile.trim();
|
||||||
if profile.is_empty() || profile == "default" {
|
if profile.is_empty() || profile == "default" {
|
||||||
return home.join("config.yaml");
|
return home.join("config.yaml");
|
||||||
@@ -598,7 +612,7 @@ mod tests {
|
|||||||
use tower::util::ServiceExt;
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
fn env_lock() -> &'static Mutex<()> {
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
crate::test_support::hermes_env_lock()
|
crate::test_support::agent_env_lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn app() -> axum::Router {
|
fn app() -> axum::Router {
|
||||||
@@ -612,7 +626,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -763,12 +776,12 @@ mod tests {
|
|||||||
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
|
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
|
||||||
let _guard = env_lock().lock().expect("env lock");
|
let _guard = env_lock().lock().expect("env lock");
|
||||||
let base_url = spawn_mock_model_server().await;
|
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-{}",
|
"mnote-page-ai-workflow-disabled-tool-{}",
|
||||||
std::process::id()
|
std::process::id()
|
||||||
));
|
));
|
||||||
let _ = fs::remove_dir_all(&hermes_home);
|
let _ = fs::remove_dir_all(&agent_home);
|
||||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
let profile_dir = agent_home.join("profiles").join("mnoteai");
|
||||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||||
fs::write(
|
fs::write(
|
||||||
profile_dir.join("config.yaml"),
|
profile_dir.join("config.yaml"),
|
||||||
@@ -777,7 +790,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.expect("profile config");
|
.expect("profile config");
|
||||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
|
||||||
|
|
||||||
let response = app()
|
let response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -821,20 +834,20 @@ mod tests {
|
|||||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
assert_eq!(payload["code"], "mnote_tool_disabled");
|
assert_eq!(payload["code"], "mnote_tool_disabled");
|
||||||
|
|
||||||
std::env::remove_var("HERMES_HOME");
|
std::env::remove_var("MNOTE_AGENT_HOME");
|
||||||
let _ = fs::remove_dir_all(&hermes_home);
|
let _ = fs::remove_dir_all(&agent_home);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
|
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
|
||||||
let _guard = env_lock().lock().expect("env lock");
|
let _guard = env_lock().lock().expect("env lock");
|
||||||
let base_url = spawn_out_of_scope_mock_model_server().await;
|
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-{}",
|
"mnote-page-ai-workflow-selection-scope-{}",
|
||||||
std::process::id()
|
std::process::id()
|
||||||
));
|
));
|
||||||
let _ = fs::remove_dir_all(&hermes_home);
|
let _ = fs::remove_dir_all(&agent_home);
|
||||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
let profile_dir = agent_home.join("profiles").join("mnoteai");
|
||||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||||
fs::write(
|
fs::write(
|
||||||
profile_dir.join("config.yaml"),
|
profile_dir.join("config.yaml"),
|
||||||
@@ -843,7 +856,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.expect("profile config");
|
.expect("profile config");
|
||||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
|
||||||
|
|
||||||
let response = app()
|
let response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -888,20 +901,20 @@ mod tests {
|
|||||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
|
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
|
||||||
|
|
||||||
std::env::remove_var("HERMES_HOME");
|
std::env::remove_var("MNOTE_AGENT_HOME");
|
||||||
let _ = fs::remove_dir_all(&hermes_home);
|
let _ = fs::remove_dir_all(&agent_home);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() {
|
async fn block_edit_workflow_surfaces_model_summary_for_read_and_edit_request() {
|
||||||
let _guard = env_lock().lock().expect("env lock");
|
let _guard = env_lock().lock().expect("env lock");
|
||||||
let base_url = spawn_read_and_edit_mock_model_server().await;
|
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-{}",
|
"mnote-page-ai-workflow-read-summary-{}",
|
||||||
std::process::id()
|
std::process::id()
|
||||||
));
|
));
|
||||||
let _ = fs::remove_dir_all(&hermes_home);
|
let _ = fs::remove_dir_all(&agent_home);
|
||||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
let profile_dir = agent_home.join("profiles").join("mnoteai");
|
||||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||||
fs::write(
|
fs::write(
|
||||||
profile_dir.join("config.yaml"),
|
profile_dir.join("config.yaml"),
|
||||||
@@ -910,7 +923,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.expect("profile config");
|
.expect("profile config");
|
||||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
std::env::set_var("MNOTE_AGENT_HOME", &agent_home);
|
||||||
|
|
||||||
let response = app()
|
let response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -962,7 +975,7 @@ mod tests {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.contains("测试123"));
|
.contains("测试123"));
|
||||||
|
|
||||||
std::env::remove_var("HERMES_HOME");
|
std::env::remove_var("MNOTE_AGENT_HOME");
|
||||||
let _ = fs::remove_dir_all(&hermes_home);
|
let _ = fs::remove_dir_all(&agent_home);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1164,7 +1164,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -677,18 +677,18 @@ fn fallback_search_dataset(workspace_id: &str) -> Value {
|
|||||||
"updatedAt": "2026-04-28T00:00:00Z"
|
"updatedAt": "2026-04-28T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "doc_hermes",
|
"id": "doc_skill_graph",
|
||||||
"workspaceId": workspace_id,
|
"workspaceId": workspace_id,
|
||||||
"title": "Hermes",
|
"title": "SkillGraph",
|
||||||
"rawText": "Hermes 技能知识图谱开发 Wolai aline fixture",
|
"rawText": "SkillGraph 技能知识图谱开发 Wolai aline fixture",
|
||||||
"createdAt": "2026-04-30T00:00:00Z",
|
"createdAt": "2026-04-30T00:00:00Z",
|
||||||
"updatedAt": "2026-04-30T00:00:00Z"
|
"updatedAt": "2026-04-30T00:00:00Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "doc_hermes_skill",
|
"id": "doc_skill_path",
|
||||||
"workspaceId": workspace_id,
|
"workspaceId": workspace_id,
|
||||||
"title": "技能知识图谱开发",
|
"title": "技能知识图谱开发",
|
||||||
"rawText": "Hermes 页面路径 个人 软件开发",
|
"rawText": "SkillGraph 页面路径 个人 软件开发",
|
||||||
"createdAt": "2026-04-30T00:00:00Z",
|
"createdAt": "2026-04-30T00:00:00Z",
|
||||||
"updatedAt": "2026-04-30T00:00:00Z"
|
"updatedAt": "2026-04-30T00:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -777,7 +777,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -908,7 +907,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -928,7 +926,7 @@ mod tests {
|
|||||||
.body(Body::from(
|
.body(Body::from(
|
||||||
json!({
|
json!({
|
||||||
"workspaceId": "ws_demo",
|
"workspaceId": "ws_demo",
|
||||||
"query": "Hermes"
|
"query": "SkillGraph"
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
))
|
))
|
||||||
@@ -944,7 +942,7 @@ mod tests {
|
|||||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
assert_eq!(payload["results"], json!([]));
|
assert_eq!(payload["results"], json!([]));
|
||||||
assert_eq!(payload["meta"]["degraded"], true);
|
assert_eq!(payload["meta"]["degraded"], true);
|
||||||
assert!(!payload.to_string().contains("Hermes 技能知识图谱开发"));
|
assert!(!payload.to_string().contains("SkillGraph 技能知识图谱开发"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1520,7 +1518,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -333,7 +332,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -403,7 +401,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -460,7 +457,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn session_returns_admin_for_local_access_policy_admin() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
let policy_root = std::env::temp_dir().join(format!(
|
let policy_root = std::env::temp_dir().join(format!(
|
||||||
@@ -484,7 +481,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -359,7 +359,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -2726,7 +2726,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: true,
|
enable_debug_shell_routes: true,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -3072,7 +3071,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: true,
|
enable_debug_shell_routes: true,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -3818,7 +3816,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: true,
|
enable_debug_shell_routes: true,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ use crate::routes::local_folder_source::{
|
|||||||
self as local_folder_source, ensure_local_workspace_read_access_with_state,
|
self as local_folder_source, ensure_local_workspace_read_access_with_state,
|
||||||
ensure_local_workspace_write_access_with_state,
|
ensure_local_workspace_write_access_with_state,
|
||||||
};
|
};
|
||||||
|
use crate::routes::vault_extension_token;
|
||||||
use crate::routes::vault_store::{
|
use crate::routes::vault_store::{
|
||||||
self, SecretPatch, VaultAccountSlot, VaultCreateInput, VaultItemStatus, VaultSecretSlot,
|
self, SecretPatch, VaultAccountSlot, VaultCreateInput, VaultItemStatus, VaultLoginSession,
|
||||||
VaultUpdateInput,
|
VaultSecretSlot, VaultSessionCookie, VaultUpdateInput,
|
||||||
};
|
};
|
||||||
use axum::extract::{Extension, Path, Query, State};
|
use axum::extract::{Extension, Path, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
||||||
@@ -1312,6 +1313,270 @@ pub async fn login_ai_item(
|
|||||||
Ok(ok_response(&context, result))
|
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
|
/// POST /api/vault/ai/items/{id}/session — human/browser cookie write-back
|
||||||
/// body: { cookieHeader, expiresAt?, source? }
|
/// body: { cookieHeader, expiresAt?, source? }
|
||||||
pub async fn put_ai_session(
|
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
@@ -328,7 +328,6 @@ pub async fn document_page_shell(
|
|||||||
vault_nav_href={vault_nav_href}
|
vault_nav_href={vault_nav_href}
|
||||||
/>
|
/>
|
||||||
});
|
});
|
||||||
let hermes_settings_config_script = render_hermes_settings_config_script();
|
|
||||||
let html = format!(
|
let html = format!(
|
||||||
r#"<!doctype html>
|
r#"<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -349,7 +348,6 @@ pub async fn document_page_shell(
|
|||||||
{}
|
{}
|
||||||
{}
|
{}
|
||||||
{}
|
{}
|
||||||
{}
|
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"#,
|
||||||
escape_html(title),
|
escape_html(title),
|
||||||
@@ -364,7 +362,6 @@ pub async fn document_page_shell(
|
|||||||
escape_script_json(&snapshot_json),
|
escape_script_json(&snapshot_json),
|
||||||
escape_script_json(&bootstrap_json),
|
escape_script_json(&bootstrap_json),
|
||||||
escape_script_json(&panes_bootstrap_json),
|
escape_script_json(&panes_bootstrap_json),
|
||||||
hermes_settings_config_script,
|
|
||||||
secondary_snapshot_json
|
secondary_snapshot_json
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|value| format!(r#"<script id="__MNOTE_SECONDARY_PAGE_AGGREGATE__" type="application/json">{}</script>"#, escape_script_json(value)))
|
.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(
|
pub(crate) fn build_editor_bootstrap_json_with_ids(
|
||||||
aggregate: &PageAggregate,
|
aggregate: &PageAggregate,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
@@ -2511,15 +2455,6 @@ pub async fn sidebar_tree_runtime_asset() -> Response {
|
|||||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
.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 {
|
pub async fn sidebar_page_ai_runtime_asset() -> Response {
|
||||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js");
|
const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js");
|
||||||
Response::builder()
|
Response::builder()
|
||||||
@@ -2534,104 +2469,6 @@ pub async fn sidebar_page_ai_runtime_asset() -> Response {
|
|||||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
.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 {
|
pub async fn sidebar_page_ai_pi_lab_runtime_asset() -> Response {
|
||||||
const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-runtime.js");
|
const JS: &str = include_str!("../../browser/sidebar-page-ai-pi-lab-runtime.js");
|
||||||
Response::builder()
|
Response::builder()
|
||||||
@@ -3749,7 +3586,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -3807,7 +3643,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -3929,7 +3764,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: Some("http://127.0.0.1:9".into()),
|
convex_url: Some("http://127.0.0.1:9".into()),
|
||||||
convex_admin_key: Some("test-admin-key".into()),
|
convex_admin_key: Some("test-admin-key".into()),
|
||||||
@@ -4072,7 +3906,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -4447,7 +4280,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mnote_browser_runtime_assets_are_not_cached_during_dev_hot() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
@@ -4476,7 +4309,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mnote_browser_runtime_module_imports_carry_dev_hot_buster() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
@@ -4508,7 +4341,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn editor_runtime_preload_links_use_dev_hot_buster() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
@@ -4536,7 +4369,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn leptos_tiptap_entry_imports_carry_dev_hot_buster() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
@@ -4570,7 +4403,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dev_hot_runtime_serves_page_block_pane_navigation_bridge() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
@@ -4841,7 +4674,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -4910,7 +4742,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -5069,7 +4900,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
@@ -5339,7 +5169,12 @@ mod tests {
|
|||||||
assert!(html.contains("Local Shell"));
|
assert!(html.contains("Local Shell"));
|
||||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||||
assert!(html.contains("\"saveEndpoint\":\"/api/page-body/write\""));
|
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("asset.png"));
|
||||||
assert!(html.contains("data-row-kind="));
|
assert!(html.contains("data-row-kind="));
|
||||||
assert!(html.contains("data-page-openable=\"false\""));
|
assert!(html.contains("data-page-openable=\"false\""));
|
||||||
@@ -5620,7 +5455,6 @@ mod tests {
|
|||||||
enable_debug_shell_routes: false,
|
enable_debug_shell_routes: false,
|
||||||
enable_editor_actor: true,
|
enable_editor_actor: true,
|
||||||
enable_page_ai_pi_lab: false,
|
enable_page_ai_pi_lab: false,
|
||||||
hermes_base_path: "/api/hermes".into(),
|
|
||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
|||||||
@@ -241,25 +241,12 @@ mod tests {
|
|||||||
include_str!("../../../browser/sidebar-workspace-runtime.js");
|
include_str!("../../../browser/sidebar-workspace-runtime.js");
|
||||||
const SIDEBAR_PAGE_TREE_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_TREE_RUNTIME_JS: &str =
|
||||||
include_str!("../../../browser/sidebar-page-tree-runtime.js");
|
include_str!("../../../browser/sidebar-page-tree-runtime.js");
|
||||||
// Browser Page AI runtime is served as a module; keep this include explicit so
|
// Page AI 产品面:facade(Pi 入口桥接)+ Pi Lab runtime。Hermes/OpenCode 子模块已物理删除。
|
||||||
// resume/journal contract checks inspect the actual shipped JS.
|
|
||||||
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
||||||
include_str!("../../../browser/sidebar-page-ai-runtime.js");
|
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 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 =
|
const SIDEBAR_PAGE_SETTINGS_RUNTIME_JS: &str =
|
||||||
include_str!("../../../browser/sidebar-page-settings-runtime.js");
|
include_str!("../../../browser/sidebar-page-settings-runtime.js");
|
||||||
const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str =
|
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_PAGE_TREE_RUNTIME_JS.contains("application/x-mnote-page-tree-node"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("dragstart"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("dragstart"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("drop"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("drop"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-drop-feedback"));
|
// drop feedback 已外置到 filetree-dnd / page-tree runtime;tree 仍委托 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("action: 'move'"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebar-file-tree-root"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebar-file-tree-root"));
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.open"));
|
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.open"));
|
||||||
@@ -623,7 +615,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_layout_adds_dev_hot_cache_buster_to_browser_runtime_scripts() {
|
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()
|
.lock()
|
||||||
.expect("env lock");
|
.expect("env lock");
|
||||||
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
std::env::set_var("MNOTE_WEB_DEV_HOT_RELOAD", "1");
|
||||||
@@ -745,6 +737,8 @@ mod tests {
|
|||||||
< html.find(r#"data-testid="wolai-floating-ai""#)
|
< html.find(r#"data-testid="wolai-floating-ai""#)
|
||||||
);
|
);
|
||||||
assert!(html.contains(r#"data-mnote-action="open-page-ai-pi-lab""#));
|
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-page-ai""#));
|
||||||
assert!(!html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
|
assert!(!html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
|
||||||
assert!(html.contains(r#"data-icon="travel_explore""#));
|
assert!(html.contains(r#"data-icon="travel_explore""#));
|
||||||
@@ -755,6 +749,33 @@ mod tests {
|
|||||||
assert!(!html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
|
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 AI:Pi 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]
|
#[test]
|
||||||
fn sidebar_settings_runtime_routes_index_and_ocr_to_knowledge_rag_settings() {
|
fn sidebar_settings_runtime_routes_index_and_ocr_to_knowledge_rag_settings() {
|
||||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
|
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
|
||||||
@@ -879,196 +900,43 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_ai_fast_path_is_not_local_first_main_path() {
|
fn page_ai_facade_is_pi_lab_bridge_only() {
|
||||||
assert!(
|
// facade 只桥接 Pi Lab;不承载 Hermes session / ACP / OpenCode host。
|
||||||
SIDEBAR_PAGE_AI_RUNTIME_JS
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("export function createSidebarPageAiRuntime"));
|
||||||
.contains("if (currentSourceKind() === 'local_folder') return false;"),
|
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("window.createSidebarPageAiPiLabRuntime"));
|
||||||
"local-first 页面 AI 不应继续加厚 page-ai fast-path;本地编辑应走受控文件工具"
|
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]
|
#[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_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
|
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
|
||||||
.contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind"));
|
.contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind"));
|
||||||
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
|
assert!(SIDEBAR_WORKSPACE_RUNTIME_JS
|
||||||
.contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri"));
|
.contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageContext: scopedContext.pageContext"));
|
// 已退役:OCR sidecar / Hermes agent 切换 / ACP session store 前端
|
||||||
assert!(
|
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"));
|
||||||
SIDEBAR_PAGE_AI_RUNTIME_JS
|
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("ocrRootRelativePath"));
|
||||||
.contains("if (currentSourceKind() === 'local_folder') return false;"),
|
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("切换到 Hermes"));
|
||||||
"local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope"
|
assert!(!SIDEBAR_PAGE_AI_PI_LAB_RUNTIME_JS.contains("切换到 OpenCode"));
|
||||||
);
|
|
||||||
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)"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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("tree:local-folder-watch-batch"));
|
||||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("viaEventBus: true"));
|
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("viaEventBus: true"));
|
||||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("emitSyntheticWatchBatch"));
|
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("emitSyntheticWatchBatch"));
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
|
// Page AI 写回本地文件夹:由 Pi Lab runtime 调 event bus,不再经 legacy drawer。
|
||||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("synthetic_page_ai_receipt"));
|
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("__mnoteLocalFolderEventBus"));
|
||||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("startLocalFolderWatcher"));
|
assert!(TREE_LIVE_CONTROLLER_JS.contains("startLocalFolderWatcher"));
|
||||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||||
@@ -2032,9 +1902,17 @@ mod tests {
|
|||||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("recordFileTreeActionStatus('blocked'")
|
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("recordFileTreeActionStatus('blocked'")
|
||||||
);
|
);
|
||||||
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("/api/tree/filetree/drop-preflight"));
|
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("/api/tree/filetree/drop-preflight"));
|
||||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
assert!(
|
||||||
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds"));
|
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'"));
|
.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]
|
#[test]
|
||||||
|
|||||||
@@ -220,7 +220,8 @@ mod tests {
|
|||||||
assert!(MNOTE_CSS.len() > 2000);
|
assert!(MNOTE_CSS.len() > 2000);
|
||||||
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装;
|
// CSS 已拆分为模块化文件,通过 concat!(include_str!()) 组装;
|
||||||
// 当前包含主壳、Page AI、搜索、toast、debug 与 vault 样式,继续用上限防止意外重复打包。
|
// 当前包含主壳、Page AI、搜索、toast、debug 与 vault 样式,继续用上限防止意外重复打包。
|
||||||
assert!(MNOTE_CSS.len() < 220000);
|
// vault workbench 样式增长后合计约 234KB;上限放宽到 280KB。
|
||||||
|
assert!(MNOTE_CSS.len() < 280000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2002,37 +2002,6 @@ button.wolai-page-ai-history-main span {
|
|||||||
border-color: rgba(27, 28, 28, 0.32);
|
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 {
|
.wolai-page-ai-composer-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 36px;
|
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);
|
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 {
|
.mnote-vault-field-row > label {
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
color: #6d6a65;
|
color: #6d6a65;
|
||||||
@@ -556,6 +594,114 @@
|
|||||||
line-height: 18px;
|
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"] {
|
.mnote-vault-folder-controls input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
+2
-79
@@ -1,15 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/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 后端
|
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||||
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 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_HOME:opencode 专用运行目录
|
|
||||||
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
|
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
|
||||||
* - MNOTE_TURSO_LOCAL_PATH:libsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
|
* - MNOTE_TURSO_LOCAL_PATH:libsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
|
||||||
* - MNOTE_PAGE_AI_PI_WARMUP:设为 "1" or "true" 时,在 mnote-web 可用后预启动 Pi Lab runtime / MCP cache
|
* - MNOTE_PAGE_AI_PI_WARMUP:设为 "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 pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||||
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
||||||
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
|
|
||||||
const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane";
|
const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane";
|
||||||
|
|
||||||
function hasCommand(command) {
|
function hasCommand(command) {
|
||||||
@@ -79,41 +75,6 @@ function buildDefaultBackendCommand(port) {
|
|||||||
return `${pythonBin} -m uvicorn app.main:app --reload --port ${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) {
|
function isEnabledEnv(value) {
|
||||||
const normalized = String(value || "").toLowerCase();
|
const normalized = String(value || "").toLowerCase();
|
||||||
return normalized === "1" || normalized === "true";
|
return normalized === "1" || normalized === "true";
|
||||||
@@ -125,12 +86,6 @@ function shouldStartBackend(env = process.env) {
|
|||||||
return isEnabledEnv(env.ENABLE_BACKEND);
|
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) {
|
function resolveRuntimePlan(env = process.env) {
|
||||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||||
const skipGateway = false;
|
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_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_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||||
MNOTE_KNOWLEDGE_PROVIDER: env.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy",
|
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,
|
...controlPlaneEnv,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -195,17 +149,6 @@ const tasks = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(shouldStartOpencode(process.env)
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: "opencode",
|
|
||||||
command:
|
|
||||||
process.env.OPENCODE_CMD ||
|
|
||||||
buildDefaultOpencodeCommand(opencodePortFromEnv),
|
|
||||||
cwd: rootDir,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function findTask(name) {
|
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", "已跳过 opencode(SKIP_OPENCODE=1)。");
|
|
||||||
} else if (!shouldStartOpencode(process.env)) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -744,7 +669,6 @@ if (require.main === module) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildDefaultOpencodeCommand,
|
|
||||||
collectStaleMnoteWebCargoPids,
|
collectStaleMnoteWebCargoPids,
|
||||||
ensurePortFree,
|
ensurePortFree,
|
||||||
getListeningPidsByPort,
|
getListeningPidsByPort,
|
||||||
@@ -757,7 +681,6 @@ module.exports = {
|
|||||||
resolveBackendExecutable,
|
resolveBackendExecutable,
|
||||||
schedulePiLabWarmup,
|
schedulePiLabWarmup,
|
||||||
shouldStartBackend,
|
shouldStartBackend,
|
||||||
shouldStartOpencode,
|
|
||||||
stopStaleMnoteWebCargoProcesses,
|
stopStaleMnoteWebCargoProcesses,
|
||||||
terminatePid,
|
terminatePid,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ const { spawn } = require("node:child_process");
|
|||||||
const net = require("node:net");
|
const net = require("node:net");
|
||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const {
|
const {
|
||||||
buildDefaultOpencodeCommand,
|
|
||||||
collectStaleMnoteWebCargoPids,
|
collectStaleMnoteWebCargoPids,
|
||||||
resolveBackendExecutable,
|
resolveBackendExecutable,
|
||||||
ensurePortFree,
|
ensurePortFree,
|
||||||
@@ -11,7 +10,6 @@ const {
|
|||||||
isPortFree,
|
isPortFree,
|
||||||
resolveRuntimePlan,
|
resolveRuntimePlan,
|
||||||
shouldStartBackend,
|
shouldStartBackend,
|
||||||
shouldStartOpencode,
|
|
||||||
stopStaleMnoteWebCargoProcesses,
|
stopStaleMnoteWebCargoProcesses,
|
||||||
} = require("./desktop-hot.js");
|
} = require("./desktop-hot.js");
|
||||||
|
|
||||||
@@ -142,10 +140,10 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
|
|||||||
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
MNOTE_WEB_BIND: "0.0.0.0:3000",
|
||||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||||
MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy",
|
MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy",
|
||||||
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
|
|
||||||
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
|
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
|
||||||
MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db",
|
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 控制面后端", () => {
|
test("热启动计划支持 libSQL local 控制面后端", () => {
|
||||||
@@ -189,24 +187,6 @@ test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
|
|||||||
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false);
|
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 () => {
|
test("isHttpHealthy 只把 2xx HTTP health 视为可复用服务", async () => {
|
||||||
const server = net.createServer((socket) => {
|
const server = net.createServer((socket) => {
|
||||||
socket.once("data", () => {
|
socket.once("data", () => {
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ function devHotBindAddr(env = process.env) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDevHotEnv(baseEnv = 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";
|
const controlPlaneBackend = String(baseEnv.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local";
|
||||||
if (controlPlaneBackend === "sqlite") {
|
if (controlPlaneBackend === "sqlite") {
|
||||||
throw new Error("dev:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced");
|
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_BIND: devHotBindAddr(baseEnv),
|
||||||
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
||||||
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
|
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
|
||||||
MNOTE_OPENCODE_BASE_URL: opencodeBaseUrl,
|
|
||||||
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
|
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
|
||||||
MNOTE_PAGE_AI_PI_WARMUP: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP ?? "1").trim() || "1",
|
MNOTE_PAGE_AI_PI_WARMUP: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP ?? "1").trim() || "1",
|
||||||
MNOTE_PAGE_AI_PI_WARMUP_SEND: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP_SEND ?? "0").trim() || "0",
|
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
Reference in New Issue
Block a user